Add support for inline video playback of gifs in Conversation.

This commit is contained in:
Alex Hart 2021-04-20 15:12:35 -03:00 committed by Greyson Parrelli
parent 32d79ead15
commit 281630e751
45 changed files with 1364 additions and 408 deletions

View file

@ -8,11 +8,14 @@ import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
@ -20,13 +23,14 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable {
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ConversationMessage messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@ -38,7 +42,9 @@ public interface BindableConversationItem extends Unbindable {
@Nullable String searchQuery,
boolean pulseMention,
boolean hasWallpaper,
boolean isMessageRequestAccepted);
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean canPlayInline);
ConversationMessage getConversationMessage();
@ -68,6 +74,7 @@ public interface BindableConversationItem extends Unbindable {
void onJoinGroupCallClicked();
void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId);
void onEnableCallNotificationsClicked();
void onPlayInlineContent(ConversationMessage conversationMessage);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);

View file

@ -12,7 +12,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Lifecycle;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
@ -20,6 +19,7 @@ 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.ViewUtil;
import java.util.List;
@ -33,6 +33,8 @@ public class ConversationItemThumbnail extends FrameLayout {
private Outliner outliner;
private Outliner pulseOutliner;
private boolean borderless;
private int[] normalBounds;
private int[] gifBounds;
public ConversationItemThumbnail(Context context) {
super(context);
@ -61,14 +63,28 @@ public class ConversationItemThumbnail extends FrameLayout {
outliner.setColor(ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_20));
int gifWidth = ViewUtil.dpToPx(260);
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));
normalBounds = new int[]{
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)
};
gifWidth = typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_gifWidth, gifWidth);
typedArray.recycle();
} else {
normalBounds = new int[]{0, 0, 0, 0};
}
gifBounds = new int[]{
gifWidth,
gifWidth,
1,
Integer.MAX_VALUE
};
}
@SuppressWarnings("SuspiciousNameCombination")
@ -89,6 +105,18 @@ public class ConversationItemThumbnail extends FrameLayout {
}
}
public void hideThumbnailView() {
thumbnail.setAlpha(0f);
}
public void showThumbnailView() {
thumbnail.setAlpha(1f);
}
public @Nullable CornerMask getCornerMask() {
return cornerMask;
}
public void setPulseOutliner(@NonNull Outliner outliner) {
this.pulseOutliner = outliner;
}
@ -138,6 +166,13 @@ public class ConversationItemThumbnail extends FrameLayout {
boolean showControls, boolean isPreview)
{
if (slides.size() == 1) {
Slide slide = slides.get(0);
if (slide.isVideoGif()) {
setThumbnailBounds(gifBounds);
} else {
setThumbnailBounds(normalBounds);
}
thumbnail.setVisibility(VISIBLE);
album.setVisibility(GONE);
@ -168,4 +203,8 @@ public class ConversationItemThumbnail extends FrameLayout {
thumbnail.setDownloadClickListener(listener);
album.setDownloadClickListener(listener);
}
private void setThumbnailBounds(@NonNull int[] bounds) {
thumbnail.setBounds(bounds[0], bounds[1], bounds[2], bounds[3]);
}
}

View file

@ -7,9 +7,11 @@ import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.graphics.drawable.shapes.RoundRectShape;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class CornerMask {
@ -20,19 +22,24 @@ public class CornerMask {
private final RectF bounds = new RectF();
public CornerMask(@NonNull View view) {
this(view, null);
}
public CornerMask(@NonNull View view, @Nullable CornerMask toClone) {
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));
if (toClone != null) {
System.arraycopy(toClone.radii, 0, radii, 0, radii.length);
}
}
public void mask(Canvas canvas) {
bounds.left = 0;
bounds.top = 0;
bounds.right = canvas.getWidth();
bounds.bottom = canvas.getHeight();
bounds.set(canvas.getClipBounds());
corners.reset();
corners.addRoundRect(bounds, radii, Path.Direction.CW);
@ -72,4 +79,8 @@ public class CornerMask {
public void setBottomLeftRadius(int radius) {
radii[6] = radii[7] = radius;
}
public float[] getRadii() {
return radii;
}
}

View file

@ -15,13 +15,17 @@ import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class MaskView extends View {
private View target;
private ViewGroup activityContentView;
private Paint maskPaint;
private Rect drawingRect = new Rect();
private float targetParentTranslationY;
private MaskTarget maskTarget;
private ViewGroup activityContentView;
private Paint maskPaint;
private Rect drawingRect = new Rect();
private float targetParentTranslationY;
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
@ -50,15 +54,15 @@ public class MaskView extends View {
activityContentView = getRootView().findViewById(android.R.id.content);
}
public void setTarget(@Nullable View target) {
if (this.target != null) {
this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener);
public void setTarget(@Nullable MaskTarget maskTarget) {
if (this.maskTarget != null) {
removeOnDrawListener(this.maskTarget, onDrawListener);
}
this.target = target;
this.maskTarget = maskTarget;
if (this.target != null) {
this.target.getViewTreeObserver().addOnDrawListener(onDrawListener);
if (this.maskTarget != null) {
addOnDrawListener(maskTarget, onDrawListener);
}
invalidate();
@ -72,26 +76,77 @@ public class MaskView extends View {
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
if (target == null || !target.isAttachedToWindow()) {
if (nothingToMask(maskTarget)) {
return;
}
target.getDrawingRect(drawingRect);
activityContentView.offsetDescendantRectToMyCoords(target, drawingRect);
maskTarget.getPrimaryTarget().getDrawingRect(drawingRect);
activityContentView.offsetDescendantRectToMyCoords(maskTarget.getPrimaryTarget(), drawingRect);
drawingRect.top += targetParentTranslationY;
drawingRect.bottom += targetParentTranslationY;
Bitmap mask = Bitmap.createBitmap(target.getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
Bitmap mask = Bitmap.createBitmap(maskTarget.getPrimaryTarget().getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
Canvas maskCanvas = new Canvas(mask);
target.draw(maskCanvas);
maskTarget.draw(maskCanvas);
canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) target.getLayoutParams();
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) maskTarget.getPrimaryTarget().getLayoutParams();
canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint);
mask.recycle();
}
private static void removeOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
for (View view : maskTarget.getAllTargets()) {
if (view != null) {
view.getViewTreeObserver().removeOnDrawListener(onDrawListener);
}
}
}
private static void addOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
for (View view : maskTarget.getAllTargets()) {
if (view != null) {
view.getViewTreeObserver().addOnDrawListener(onDrawListener);
}
}
}
private static boolean nothingToMask(@Nullable MaskTarget maskTarget) {
if (maskTarget == null) {
return true;
}
for (View view : maskTarget.getAllTargets()) {
if (view == null || !view.isAttachedToWindow()) {
return true;
}
}
return false;
}
public static class MaskTarget {
private final View primaryTarget;
public MaskTarget(@NonNull View primaryTarget) {
this.primaryTarget = primaryTarget;
}
final @NonNull View getPrimaryTarget() {
return primaryTarget;
}
protected @NonNull List<View> getAllTargets() {
return Collections.singletonList(primaryTarget);
}
protected void draw(@NonNull Canvas canvas) {
primaryTarget.draw(canvas);
}
}
}

View file

@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import java.util.Collections;
import java.util.List;
@ -47,9 +48,9 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
private final Context context;
private final SimpleExoPlayer player;
private final VoiceNoteQueueDataAdapter queueDataAdapter;
private final VoiceNoteMediaSourceFactory mediaSourceFactory;
private final ConcatenatingMediaSource dataSource;
private final VoiceNoteQueueDataAdapter queueDataAdapter;
private final AttachmentMediaSourceFactory mediaSourceFactory;
private final ConcatenatingMediaSource dataSource;
private boolean canLoadMore;
private Uri latestUri = Uri.EMPTY;
@ -57,7 +58,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
VoiceNotePlaybackPreparer(@NonNull Context context,
@NonNull SimpleExoPlayer player,
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter,
@NonNull VoiceNoteMediaSourceFactory mediaSourceFactory)
@NonNull AttachmentMediaSourceFactory mediaSourceFactory)
{
this.context = context;
this.player = player;

View file

@ -34,6 +34,7 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import java.util.Collections;
import java.util.List;
@ -87,7 +88,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
new VoiceNoteNotificationManagerListener(),
queueDataAdapter);
VoiceNoteMediaSourceFactory mediaSourceFactory = new VoiceNoteMediaSourceFactory(this);
AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this);
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory);
voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter);

View file

@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.components.HidingLinearLayout;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.InputPanel;
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.components.SendButton;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@ -3420,7 +3421,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
@Override
public void handleReaction(@NonNull View maskTarget,
public void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener)
@ -3451,7 +3452,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
@Override
public void handleReactionDetails(@NonNull View maskTarget) {
public void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget) {
reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight());
}

View file

@ -36,12 +36,18 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.source.MediaSource;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.CachedInflater;
@ -50,6 +56,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.security.MessageDigest;
@ -100,11 +107,12 @@ public class ConversationAdapter
private final Locale locale;
private final Recipient recipient;
private final Set<ConversationMessage> selected;
private final List<ConversationMessage> fastRecords;
private final Set<Long> releasedFastRecords;
private final Calendar calendar;
private final MessageDigest digest;
private final Set<ConversationMessage> selected;
private final List<ConversationMessage> fastRecords;
private final Set<Long> releasedFastRecords;
private final Calendar calendar;
private final MessageDigest digest;
private final AttachmentMediaSourceFactory attachmentMediaSourceFactory;
private String searchQuery;
private ConversationMessage recordToPulse;
@ -113,12 +121,14 @@ public class ConversationAdapter
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
ConversationAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@NonNull Recipient recipient)
@NonNull Recipient recipient,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@Override
@ -134,17 +144,18 @@ public class ConversationAdapter
this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests;
this.locale = locale;
this.clickListener = clickListener;
this.recipient = recipient;
this.selected = new HashSet<>();
this.fastRecords = new ArrayList<>();
this.releasedFastRecords = new HashSet<>();
this.calendar = Calendar.getInstance();
this.digest = getMessageDigestOrThrow();
this.hasWallpaper = recipient.hasWallpaper();
this.isMessageRequestAccepted = true;
this.glideRequests = glideRequests;
this.locale = locale;
this.clickListener = clickListener;
this.recipient = recipient;
this.selected = new HashSet<>();
this.fastRecords = new ArrayList<>();
this.releasedFastRecords = new HashSet<>();
this.calendar = Calendar.getInstance();
this.digest = getMessageDigestOrThrow();
this.hasWallpaper = recipient.hasWallpaper();
this.isMessageRequestAccepted = true;
this.attachmentMediaSourceFactory = attachmentMediaSourceFactory;
setHasStableIds(true);
}
@ -257,7 +268,9 @@ public class ConversationAdapter
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper,
isMessageRequestAccepted);
isMessageRequestAccepted,
attachmentMediaSourceFactory,
conversationMessage == inlineContent);
if (conversationMessage == recordToPulse) {
recordToPulse = null;
@ -610,7 +623,14 @@ public class ConversationAdapter
}
}
static class ConversationViewHolder extends RecyclerView.ViewHolder {
public void playInlineContent(@Nullable ConversationMessage conversationMessage) {
if (this.inlineContent != conversationMessage) {
this.inlineContent = conversationMessage;
notifyDataSetChanged();
}
}
final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
public ConversationViewHolder(final @NonNull View itemView) {
super(itemView);
}
@ -618,6 +638,36 @@ public class ConversationAdapter
public BindableConversationItem getBindable() {
return (BindableConversationItem) itemView;
}
@Override
public void showProjectionArea() {
getBindable().showProjectionArea();
}
@Override
public void hideProjectionArea() {
getBindable().hideProjectionArea();
}
@Override
public @Nullable MediaSource getMediaSource() {
return getBindable().getMediaSource();
}
@Override
public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() {
return getBindable().getPlaybackPolicyEnforcer();
}
@NonNull
public @Override GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
return getBindable().getProjection(recyclerView);
}
@Override
public boolean canPlayContent() {
return getBindable().canPlayContent();
}
}
static class StickyHeaderViewHolder extends RecyclerView.ViewHolder {
@ -688,6 +738,6 @@ public class ConversationAdapter
interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(ConversationMessage item);
void onItemLongClick(View maskTarget, ConversationMessage item);
void onItemLongClick(View itemView, ConversationMessage item);
}
}

View file

@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.ConversationScrollToView;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
@ -97,6 +98,10 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment;
@ -148,6 +153,7 @@ import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.guava.Optional;
@ -177,6 +183,7 @@ public class ConversationFragment extends LoggingFragment {
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private FrameLayout videoContainer;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private RecyclerView.ItemDecoration inlineDateDecoration;
@ -204,6 +211,8 @@ public class ConversationFragment extends LoggingFragment {
private View toolbarShadow;
private Stopwatch startupStopwatch;
private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler;
public static void prepare(@NonNull Context context) {
FrameLayout parent = new FrameLayout(context);
parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT));
@ -226,6 +235,7 @@ public class ConversationFragment extends LoggingFragment {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
videoContainer = view.findViewById(R.id.video_container);
list = view.findViewById(android.R.id.list);
composeDivider = view.findViewById(R.id.compose_divider);
@ -250,13 +260,16 @@ public class ConversationFragment extends LoggingFragment {
typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false);
giphyMp4ProjectionRecycler = initializeGiphyMp4();
new ConversationItemSwipeCallback(
conversationMessage -> actionMode == null &&
MenuState.canReplyToMessage(recipient.get(),
MenuState.isActionMessage(conversationMessage.getMessageRecord()),
conversationMessage.getMessageRecord(),
messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage
this::handleReplyMessage,
giphyMp4ProjectionRecycler
).attachToRecyclerView(list);
setupListLayoutListeners();
@ -297,6 +310,26 @@ public class ConversationFragment extends LoggingFragment {
return view;
}
private @NonNull GiphyMp4ProjectionRecycler initializeGiphyMp4() {
int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation();
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
getViewLifecycleOwner().getLifecycle(),
videoContainer,
maxPlayback);
GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders);
GiphyMp4PlaybackController.attach(list, callback, maxPlayback);
return callback;
}
private @NonNull MaskView.MaskTarget getMaskTarget(@NonNull View itemView) {
int adapterPosition = list.getChildAdapterPosition(itemView);
View videoPlayer = giphyMp4ProjectionRecycler.getVideoPlayerAtAdapterPosition(adapterPosition);
return new ConversationItemMaskTarget((ConversationItem) itemView, videoPlayer);
}
private void setupListLayoutListeners() {
list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation());
@ -557,7 +590,7 @@ public class ConversationFragment extends LoggingFragment {
private void initializeListAdapter() {
if (this.recipient != null && this.threadId != -1) {
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get());
ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()));
adapter.setPagingController(conversationViewModel.getPagingController());
list.setAdapter(adapter);
setInlineDateDecoration(adapter);
@ -1191,14 +1224,14 @@ public class ConversationFragment extends LoggingFragment {
void onMessageActionToolbarOpened();
void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull View maskTarget,
void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
@NonNull MessageRecord messageRecord,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
void onListVerticalTranslationChanged(float translationY);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void handleReactionDetails(@NonNull View maskTarget);
void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget);
}
private class ConversationScrollListener extends OnScrollListener {
@ -1288,7 +1321,7 @@ public class ConversationFragment extends LoggingFragment {
}
@Override
public void onItemLongClick(View maskTarget, ConversationMessage conversationMessage) {
public void onItemLongClick(View itemView, ConversationMessage conversationMessage) {
if (actionMode != null) return;
@ -1304,7 +1337,7 @@ public class ConversationFragment extends LoggingFragment {
{
isReacting = true;
list.setLayoutFrozen(true);
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(conversationMessage), () -> {
listener.handleReaction(getMaskTarget(itemView), messageRecord, new ReactionsToolbarListener(conversationMessage), () -> {
isReacting = false;
list.setLayoutFrozen(false);
WindowUtil.setLightStatusBarFromTheme(requireActivity());
@ -1452,7 +1485,7 @@ public class ConversationFragment extends LoggingFragment {
public void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms) {
if (getContext() == null) return;
listener.handleReactionDetails(reactionTarget);
listener.handleReactionDetails(getMaskTarget(reactionTarget));
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
}
@ -1571,6 +1604,11 @@ public class ConversationFragment extends LoggingFragment {
refreshList();
}
}
@Override
public void onPlayInlineContent(ConversationMessage conversationMessage) {
getListAdapter().playInlineContent(conversationMessage);
}
}
public void refreshList() {

View file

@ -55,8 +55,10 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.source.MediaSource;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
@ -70,6 +72,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.BorderlessImageView;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
import org.thoughtcrime.securesms.components.CornerMask;
import org.thoughtcrime.securesms.components.DocumentView;
import org.thoughtcrime.securesms.components.LinkPreviewView;
import org.thoughtcrime.securesms.components.Outliner;
@ -87,6 +90,9 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsDownloadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
@ -100,6 +106,7 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -120,6 +127,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
@ -198,9 +206,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final Context context;
private MediaSource mediaSource;
private boolean canPlayContent;
public ConversationItem(Context context) {
this(context, null);
}
@ -260,7 +272,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Nullable String searchQuery,
boolean pulse,
boolean hasWallpaper,
boolean isMessageRequestAccepted)
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
@ -275,13 +289,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.conversationRecipient = conversationRecipient.live();
this.groupThread = conversationRecipient.isGroup();
this.recipient = messageRecord.getIndividualRecipient().live();
this.canPlayContent = false;
this.mediaSource = null;
this.recipient.observeForever(this);
this.conversationRecipient.observeForever(this);
setGutterSizes(messageRecord, groupThread);
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline);
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
setBubbleState(messageRecord, hasWallpaper);
setInteractionState(conversationMessage, pulse);
@ -675,12 +691,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
@NonNull Optional<MessageRecord> previousRecord,
@NonNull Optional<MessageRecord> nextRecord,
boolean isGroupThread,
boolean hasWallpaper,
boolean messageRequestAccepted)
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
@NonNull Optional<MessageRecord> previousRecord,
@NonNull Optional<MessageRecord> nextRecord,
boolean isGroupThread,
boolean hasWallpaper,
boolean messageRequestAccepted,
@Nullable AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
{
boolean showControls = !messageRecord.isFailed();
@ -865,6 +883,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
if (attachmentMediaSourceFactory != null &&
thumbnailSlides.size() == 1 &&
thumbnailSlides.get(0).isVideoGif() &&
thumbnailSlides.get(0) instanceof VideoSlide)
{
canPlayContent = GiphyMp4PlaybackPolicy.autoplay() || allowedToPlayInline;
mediaSource = attachmentMediaSourceFactory.createMediaSource(Objects.requireNonNull(thumbnailSlides.get(0).getUri()));
}
} else {
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
@ -1399,6 +1427,68 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return span;
}
@Override
public void showProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().showThumbnailView();
bodyBubble.setMask(null);
}
}
@Override
public void hideProjectionArea() {
if (mediaThumbnailStub != null && mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().hideThumbnailView();
mediaThumbnailStub.get().getDrawingRect(thumbnailMaskingRect);
bodyBubble.setMask(thumbnailMaskingRect);
}
}
@Override
public @Nullable MediaSource getMediaSource() {
return mediaSource;
}
@Override
public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() {
if (GiphyMp4PlaybackPolicy.autoplay()) {
return null;
} else {
return new GiphyMp4PlaybackPolicyEnforcer(() -> {
eventListener.onPlayInlineContent(null);
});
}
}
@Override
public int getAdapterPosition() {
throw new UnsupportedOperationException("Do not delegate to this method");
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
return GiphyMp4Projection.forView(recyclerView, mediaThumbnailStub.get(), mediaThumbnailStub.get().getCornerMask())
.translateX(bodyBubble.getTranslationX());
}
@Override
public boolean canPlayContent() {
return mediaThumbnailStub != null && canPlayContent;
}
public @NonNull Rect getThumbnailMaskingRect(@NonNull ViewGroup parent) {
Rect rect = new Rect();
rect.set(thumbnailMaskingRect);
parent.offsetDescendantRectToMyCoords(mediaThumbnailStub.get(), rect);
return rect;
}
public @NonNull CornerMask getThumbnailCornerMask(@NonNull View view) {
return new CornerMask(view, mediaThumbnailStub.get().getCornerMask());
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {
@ -1526,6 +1616,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
public void onClick(final View v, final Slide slide) {
if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) {
performClick();
} else if (!canPlayContent && mediaSource != null && eventListener != null) {
eventListener.onPlayInlineContent(conversationMessage);
} else if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
Intent intent = new Intent(context, MediaPreviewActivity.class);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

View file

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.LinearLayout;
@ -19,6 +21,9 @@ public class ConversationItemBodyBubble extends LinearLayout {
@Nullable private List<Outliner> outliners = Collections.emptyList();
@Nullable private OnSizeChangedListener sizeChangedListener;
private MaskDrawable maskDrawable;
private Rect mask;
public ConversationItemBodyBubble(Context context) {
super(context);
}
@ -39,6 +44,18 @@ public class ConversationItemBodyBubble extends LinearLayout {
this.sizeChangedListener = listener;
}
@Override
public void setBackground(Drawable background) {
maskDrawable = new MaskDrawable(background);
maskDrawable.setMask(mask);
super.setBackground(maskDrawable);
}
public void setMask(@Nullable Rect mask) {
this.mask = mask;
maskDrawable.setMask(mask);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.conversation;
import android.graphics.Canvas;
import android.graphics.Color;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CornerMask;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import java.util.Arrays;
import java.util.List;
public final class ConversationItemMaskTarget extends MaskView.MaskTarget {
private final ConversationItem conversationItem;
private final View videoContainer;
public ConversationItemMaskTarget(@NonNull ConversationItem conversationItem,
@Nullable View videoContainer)
{
super(conversationItem);
this.conversationItem = conversationItem;
this.videoContainer = videoContainer;
}
@Override
protected @NonNull List<View> getAllTargets() {
if (videoContainer == null) {
return super.getAllTargets();
} else {
return Arrays.asList(conversationItem, videoContainer);
}
}
@Override
protected void draw(@NonNull Canvas canvas) {
super.draw(canvas);
if (videoContainer == null) {
return;
}
GiphyMp4Projection projection = conversationItem.getProjection((RecyclerView) conversationItem.getParent());
CornerMask cornerMask = projection.getCornerMask();
canvas.clipRect(conversationItem.bodyBubble.getLeft(),
conversationItem.bodyBubble.getTop(),
conversationItem.bodyBubble.getRight(),
conversationItem.bodyBubble.getTop() + projection.getHeight());
canvas.drawColor(Color.BLACK);
cornerMask.mask(canvas);
}
}

View file

@ -11,6 +11,8 @@ import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4DisplayUpdater;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@ -28,14 +30,17 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private final SwipeAvailabilityProvider swipeAvailabilityProvider;
private final ConversationItemTouchListener itemTouchListener;
private final OnSwipeListener onSwipeListener;
private final GiphyMp4DisplayUpdater giphyMp4DisplayUpdater;
ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider,
@NonNull OnSwipeListener onSwipeListener)
@NonNull OnSwipeListener onSwipeListener,
@NonNull GiphyMp4DisplayUpdater giphyMp4DisplayUpdater)
{
super(0, ItemTouchHelper.END);
this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate);
this.swipeAvailabilityProvider = swipeAvailabilityProvider;
this.onSwipeListener = onSwipeListener;
this.giphyMp4DisplayUpdater = giphyMp4DisplayUpdater;
this.shouldTriggerSwipeFeedback = true;
this.canTriggerSwipe = true;
}
@ -88,12 +93,14 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, Math.abs(dx), sign);
updateVideoPlayer(recyclerView, viewHolder);
handleSwipeFeedback((ConversationItem) viewHolder.itemView, Math.abs(dx));
if (canTriggerSwipe) {
setTouchListener(recyclerView, viewHolder, Math.abs(dx));
}
} else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE || dx == 0) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, 0, 1);
updateVideoPlayer(recyclerView, viewHolder);
}
if (dx == 0) {
@ -102,6 +109,12 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
}
}
private void updateVideoPlayer(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (viewHolder instanceof GiphyMp4Playable) {
giphyMp4DisplayUpdater.updateDisplay(recyclerView, (GiphyMp4Playable) viewHolder);
}
}
private void handleSwipeFeedback(@NonNull ConversationItem item, float dx) {
if (dx > SWIPE_SUCCESS_DX && shouldTriggerSwipeFeedback) {
vibrate(item.getContext());
@ -134,7 +147,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
case MotionEvent.ACTION_CANCEL:
swipeBack = true;
shouldTriggerSwipeFeedback = false;
resetProgressIfAnimationsDisabled(viewHolder);
resetProgressIfAnimationsDisabled(recyclerView, viewHolder);
break;
}
return false;
@ -156,11 +169,12 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
recyclerView.cancelPendingInputEvents();
}
private static void resetProgressIfAnimationsDisabled(RecyclerView.ViewHolder viewHolder) {
private void resetProgressIfAnimationsDisabled(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
if (AccessibilityUtil.areAnimationsDisabled(viewHolder.itemView.getContext())) {
ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView,
0f,
getSignFromDirection(viewHolder.itemView));
updateVideoPlayer(recyclerView, viewHolder);
}
}

View file

@ -8,6 +8,7 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.views.Stub;
@ -38,7 +39,7 @@ final class ConversationReactionDelegate {
}
void show(@NonNull Activity activity,
@NonNull View maskTarget,
@NonNull MaskView.MaskTarget maskTarget,
@NonNull Recipient conversationRecipient,
@NonNull MessageRecord messageRecord,
int maskPaddingBottom)
@ -46,7 +47,7 @@ final class ConversationReactionDelegate {
resolveOverlay().show(activity, maskTarget, conversationRecipient, messageRecord, maskPaddingBottom, lastSeenDownPoint);
}
void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) {
void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) {
resolveOverlay().showMask(maskTarget, maskPaddingTop, maskPaddingBottom);
}

View file

@ -145,7 +145,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
public void show(@NonNull Activity activity,
@NonNull View maskTarget,
@NonNull MaskView.MaskTarget maskTarget,
@NonNull Recipient conversationRecipient,
@NonNull MessageRecord messageRecord,
int maskPaddingBottom,
@ -209,7 +209,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
}
public void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) {
public void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) {
maskView.setPadding(0, maskPaddingTop, 0, maskPaddingBottom);
maskView.setTarget(maskTarget);

View file

@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.button.MaterialButton;
@ -29,6 +30,7 @@ import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -40,6 +42,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collection;
@ -102,7 +105,9 @@ public final class ConversationUpdateItem extends FrameLayout
@Nullable String searchQuery,
boolean pulseMention,
boolean hasWallpaper,
boolean isMessageRequestAccepted)
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
{
this.batchSelected = batchSelected;
@ -182,6 +187,30 @@ public final class ConversationUpdateItem extends FrameLayout
public void unbind() {
}
@Override
public void showProjectionArea() {
}
@Override
public void hideProjectionArea() {
throw new UnsupportedOperationException("Call makes no sense for a conversation update item");
}
@Override
public int getAdapterPosition() {
throw new UnsupportedOperationException("Don't delegate to this method.");
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
throw new UnsupportedOperationException("ConversationUpdateItems cannot be projected into.");
}
@Override
public boolean canPlayContent() {
return false;
}
static final class RecipientObserverManager {
private final Observer<Recipient> recipientObserver;

View file

@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.conversation;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Drawable which lets you punch a hole through another drawable.
*/
public final class MaskDrawable extends Drawable {
private final RectF bounds = new RectF();
private final Path clipPath = new Path();
private Rect clipRect;
private float[] clipPathRadii;
private final Drawable wrapped;
public MaskDrawable(@NonNull Drawable wrapped) {
this.wrapped = wrapped;
}
@Override
public void draw(@NonNull Canvas canvas) {
if (clipRect == null) {
wrapped.draw(canvas);
return;
}
canvas.save();
if (clipPathRadii != null) {
clipPath.reset();
bounds.set(clipRect);
clipPath.addRoundRect(bounds, clipPathRadii, Path.Direction.CW);
canvas.clipPath(clipPath, Region.Op.DIFFERENCE);
} else {
canvas.clipRect(clipRect, Region.Op.DIFFERENCE);
}
wrapped.draw(canvas);
canvas.restore();
}
@Override
public void setAlpha(int alpha) {
wrapped.setAlpha(alpha);
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {
wrapped.setColorFilter(colorFilter);
}
@Override
public int getOpacity() {
return wrapped.getOpacity();
}
@Override
public void setBounds(int left, int top, int right, int bottom) {
super.setBounds(left, top, right, bottom);
wrapped.setBounds(left, top, right, bottom);
}
@Override
public boolean getPadding(@NonNull Rect padding) {
return wrapped.getPadding(padding);
}
public void setMask(@Nullable Rect mask) {
this.clipRect = new Rect(mask);
invalidateSelf();
}
public void setCorners(@Nullable float[] clipPathRadii) {
this.clipPathRadii = clipPathRadii;
invalidateSelf();
}
}

View file

@ -57,7 +57,7 @@ public final class ThreadBodyUtil {
for (Slide slide : record.getSlideDeck().getSlides()) {
hasVideo |= slide.hasVideo();
hasImage |= slide.hasImage();
hasGif |= slide instanceof GifSlide;
hasGif |= slide instanceof GifSlide || slide.isVideoGif();
}
if (hasGif) {

View file

@ -1,116 +0,0 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.util.SparseArray;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Logic for updating content and positioning of videos as the user scrolls the list of gifs.
*/
final class GiphyMp4AdapterPlaybackControllerCallback implements GiphyMp4AdapterPlaybackController.Callback {
private final List<GiphyMp4PlayerHolder> holders;
private final SparseArray<GiphyMp4PlayerHolder> playing;
private final SparseArray<GiphyMp4PlayerHolder> notPlaying;
GiphyMp4AdapterPlaybackControllerCallback(@NonNull List<GiphyMp4PlayerHolder> holders) {
this.holders = holders;
this.playing = new SparseArray<>(holders.size());
this.notPlaying = new SparseArray<>(holders.size());
}
@Override public void update(@NonNull List<GiphyMp4ViewHolder> holders,
@NonNull GiphyMp4PlaybackRange range)
{
stopAndReleaseAssignedVideos(range);
for (final GiphyMp4ViewHolder holder : holders) {
if (range.shouldPlayVideo(holder.getAdapterPosition())) {
startPlayback(acquireHolderForPosition(holder.getAdapterPosition()), holder);
} else {
holder.show();
}
}
for (final GiphyMp4ViewHolder holder : holders) {
GiphyMp4PlayerHolder playerHolder = getCurrentHolder(holder.getAdapterPosition());
if (playerHolder != null) {
updateDisplay(playerHolder, holder);
}
}
}
private void stopAndReleaseAssignedVideos(@NonNull GiphyMp4PlaybackRange playbackRange) {
List<Integer> markedForDeletion = new ArrayList<>(playing.size());
for (int i = 0; i < playing.size(); i++) {
if (!playbackRange.shouldPlayVideo(playing.keyAt(i))) {
notPlaying.put(playing.keyAt(i), playing.valueAt(i));
playing.valueAt(i).setMediaSource(null);
playing.valueAt(i).setOnPlaybackReady(null);
markedForDeletion.add(playing.keyAt(i));
}
}
for (final Integer key : markedForDeletion) {
playing.remove(key);
}
}
private void updateDisplay(@NonNull GiphyMp4PlayerHolder holder, @NonNull GiphyMp4ViewHolder giphyMp4ViewHolder) {
holder.getContainer().setX(giphyMp4ViewHolder.itemView.getX());
holder.getContainer().setY(giphyMp4ViewHolder.itemView.getY());
ViewGroup.LayoutParams params = holder.getContainer().getLayoutParams();
if (params.width != giphyMp4ViewHolder.itemView.getWidth() || params.height != giphyMp4ViewHolder.itemView.getHeight()) {
params.width = giphyMp4ViewHolder.itemView.getWidth();
params.height = giphyMp4ViewHolder.itemView.getHeight();
holder.getContainer().setLayoutParams(params);
}
}
private void startPlayback(@NonNull GiphyMp4PlayerHolder holder, @NonNull GiphyMp4ViewHolder giphyMp4ViewHolder) {
if (!Objects.equals(holder.getMediaSource(), giphyMp4ViewHolder.getMediaSource())) {
holder.setOnPlaybackReady(null);
giphyMp4ViewHolder.show();
holder.setOnPlaybackReady(giphyMp4ViewHolder::hide);
holder.setMediaSource(giphyMp4ViewHolder.getMediaSource());
}
}
private @Nullable GiphyMp4PlayerHolder getCurrentHolder(int adapterPosition) {
if (playing.get(adapterPosition) != null) {
return playing.get(adapterPosition);
} else if (notPlaying.get(adapterPosition) != null) {
return notPlaying.get(adapterPosition);
} else {
return null;
}
}
private @NonNull GiphyMp4PlayerHolder acquireHolderForPosition(int adapterPosition) {
GiphyMp4PlayerHolder holder = playing.get(adapterPosition);
if (holder == null) {
if (notPlaying.size() != 0) {
holder = notPlaying.get(adapterPosition);
if (holder == null) {
int key = notPlaying.keyAt(0);
holder = Objects.requireNonNull(notPlaying.get(key));
notPlaying.remove(key);
} else {
notPlaying.remove(adapterPosition);
}
} else {
holder = holders.remove(0);
}
playing.put(adapterPosition, holder);
}
return holder;
}
}

View file

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.giph.mp4;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
/**
* Updates the position and size of a GiphyMp4VideoPlayer. For use with gestures which
* move around the projectable areas videos should play back in.
*/
public interface GiphyMp4DisplayUpdater {
void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4Playable holder);
}

View file

@ -1,9 +1,7 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
@ -15,13 +13,9 @@ import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import java.util.ArrayList;
import java.util.List;
/**
@ -55,44 +49,22 @@ public class GiphyMp4Fragment extends Fragment {
GiphyMp4ViewModel viewModel = ViewModelProviders.of(requireActivity(), new GiphyMp4ViewModel.Factory(isForMms)).get(GiphyMp4ViewModel.class);
GiphyMp4MediaSourceFactory mediaSourceFactory = new GiphyMp4MediaSourceFactory(ApplicationDependencies.getOkHttpClient());
GiphyMp4Adapter adapter = new GiphyMp4Adapter(mediaSourceFactory, viewModel::saveToBlob);
List<GiphyMp4PlayerHolder> holders = injectVideoViews(frameLayout);
GiphyMp4AdapterPlaybackControllerCallback callback = new GiphyMp4AdapterPlaybackControllerCallback(holders);
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
getViewLifecycleOwner().getLifecycle(),
frameLayout,
GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults());
GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders);
recycler.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL));
recycler.setAdapter(adapter);
recycler.setItemAnimator(null);
progressBar.show();
GiphyMp4AdapterPlaybackController.attach(recycler, callback, GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults());
GiphyMp4PlaybackController.attach(recycler, callback, GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults());
viewModel.getImages().observe(getViewLifecycleOwner(), images -> {
nothingFound.setVisibility(images.isEmpty() ? View.VISIBLE : View.INVISIBLE);
adapter.submitList(images, progressBar::hide);
});
viewModel.getPagingController().observe(getViewLifecycleOwner(), adapter::setPagingController);
}
private List<GiphyMp4PlayerHolder> injectVideoViews(@NonNull ViewGroup viewGroup) {
int nPlayers = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInSearchResults();
List<GiphyMp4PlayerHolder> holders = new ArrayList<>(nPlayers);
GiphyMp4ExoPlayerProvider playerProvider = new GiphyMp4ExoPlayerProvider(requireContext());
for (int i = 0; i < nPlayers; i++) {
FrameLayout container = (FrameLayout) LayoutInflater.from(requireContext())
.inflate(R.layout.giphy_mp4_player, viewGroup, false);
GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player);
ExoPlayer exoPlayer = playerProvider.create();
GiphyMp4PlayerHolder holder = new GiphyMp4PlayerHolder(container, player);
getViewLifecycleOwner().getLifecycle().addObserver(player);
player.setExoPlayer(exoPlayer);
player.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL);
exoPlayer.addListener(holder);
holders.add(holder);
viewGroup.addView(container);
}
return holders;
}
}

View file

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.giph.mp4;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.source.MediaSource;
public interface GiphyMp4Playable {
/**
* Shows the area in which a video would be projected. Called when a video will not
* play back.
*/
void showProjectionArea();
/**
* Hides the area in which a video would be projected. Called when a video is ready
* to play back.
*/
void hideProjectionArea();
/**
* @return The MediaSource to play back in the given VideoPlayer
*/
default @Nullable MediaSource getMediaSource() {
return null;
}
/**
* A Playback policy enforcer, or null to loop forever.
*/
default @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() {
return null;
}
/**
* @return The position this item is in it's corresponding adapter
*/
int getAdapterPosition();
/**
* Width, height, and (x,y) of view which video player will "project" into
*/
@NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview);
/**
* Specifies whether the content can start playing.
*/
boolean canPlayContent();
}

View file

@ -3,33 +3,39 @@ package org.thoughtcrime.securesms.giph.mp4;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* Controls playback of gifs in a {@link GiphyMp4Adapter}. The maximum number of gifs that will play back at any one
* Controls playback of gifs in a {@link RecyclerView}. The maximum number of gifs that will play back at any one
* time is determined by the passed parameter, and the exact gifs that play back is algorithmically determined, starting
* with the center-most gifs.
* <p>
* This algorithm is devised to play back only those gifs which the user is most likely looking at.
*/
final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListener implements View.OnLayoutChangeListener {
public final class GiphyMp4PlaybackController extends RecyclerView.OnScrollListener implements View.OnLayoutChangeListener {
private final int maxSimultaneousPlayback;
private final Callback callback;
private GiphyMp4AdapterPlaybackController(@NonNull Callback callback, int maxSimultaneousPlayback) {
private GiphyMp4PlaybackController(@NonNull Callback callback, int maxSimultaneousPlayback) {
this.maxSimultaneousPlayback = maxSimultaneousPlayback;
this.callback = callback;
}
public static void attach(@NonNull RecyclerView recyclerView, @NonNull Callback callback, int maxSimultaneousPlayback) {
GiphyMp4AdapterPlaybackController controller = new GiphyMp4AdapterPlaybackController(callback, maxSimultaneousPlayback);
GiphyMp4PlaybackController controller = new GiphyMp4PlaybackController(callback, maxSimultaneousPlayback);
recyclerView.addOnScrollListener(controller);
recyclerView.addOnLayoutChangeListener(controller);
@ -57,24 +63,30 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe
return;
}
int[] firstVisiblePositions = findFirstVisibleItemPositions(layoutManager);
int[] lastVisiblePositions = findLastVisibleItemPositions(layoutManager);
List<GiphyMp4Playable> playables = new LinkedList<>();
Set<Integer> playablePositions = new HashSet<>();
GiphyMp4PlaybackRange playbackRange = getPlaybackRangeForMaximumDistance(firstVisiblePositions, lastVisiblePositions);
for (int i = 0; i < recyclerView.getChildCount(); i++) {
RecyclerView.ViewHolder holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i));
if (playbackRange != null) {
List<GiphyMp4ViewHolder> holders = new LinkedList<>();
if (holder instanceof GiphyMp4Playable) {
GiphyMp4Playable playable = (GiphyMp4Playable) holder;
playables.add(playable);
for (int i = 0; i < recyclerView.getChildCount(); i++) {
GiphyMp4ViewHolder viewHolder = (GiphyMp4ViewHolder) recyclerView.getChildViewHolder(recyclerView.getChildAt(i));
holders.add(viewHolder);
if (playable.canPlayContent()) {
playablePositions.add(playable.getAdapterPosition());
}
}
callback.update(holders, playbackRange);
}
int[] firstVisiblePositions = findFirstVisibleItemPositions(layoutManager);
int[] lastVisiblePositions = findLastVisibleItemPositions(layoutManager);
Set<Integer> playbackSet = getPlaybackSetForMaximumDistance(playablePositions, firstVisiblePositions, lastVisiblePositions);
callback.update(recyclerView, playables, playbackSet);
}
private @Nullable GiphyMp4PlaybackRange getPlaybackRangeForMaximumDistance(int[] firstVisiblePositions, int[] lastVisiblePositions) {
private @NonNull Set<Integer> getPlaybackSetForMaximumDistance(@NonNull Set<Integer> playablePositions, int[] firstVisiblePositions, int[] lastVisiblePositions) {
int firstVisiblePosition = Integer.MAX_VALUE;
int lastVisiblePosition = Integer.MIN_VALUE;
@ -83,28 +95,15 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe
lastVisiblePosition = Math.max(lastVisiblePosition, lastVisiblePositions[i]);
}
return getPlaybackRange(firstVisiblePosition, lastVisiblePosition);
return getPlaybackSet(playablePositions, firstVisiblePosition, lastVisiblePosition);
}
private @Nullable GiphyMp4PlaybackRange getPlaybackRange(int firstVisiblePosition, int lastVisiblePosition) {
int distance = lastVisiblePosition - firstVisiblePosition;
if (maxSimultaneousPlayback == 0) {
return null;
}
if (distance <= maxSimultaneousPlayback) {
return new GiphyMp4PlaybackRange(firstVisiblePosition, lastVisiblePosition);
} else {
int center = (distance / 2) + firstVisiblePosition;
if (maxSimultaneousPlayback == 1) {
return new GiphyMp4PlaybackRange(center, center);
} else {
int first = Math.max(center - maxSimultaneousPlayback / 2, firstVisiblePosition);
int last = Math.min(first + maxSimultaneousPlayback, lastVisiblePosition);
return new GiphyMp4PlaybackRange(first, last);
}
}
private @NonNull Set<Integer> getPlaybackSet(@NonNull Set<Integer> playablePositions, int firstVisiblePosition, int lastVisiblePosition) {
return Stream.rangeClosed(firstVisiblePosition, lastVisiblePosition)
.sorted(new RangeComparator(firstVisiblePosition, lastVisiblePosition))
.filter(playablePositions::contains)
.limit(maxSimultaneousPlayback)
.collect(Collectors.toSet());
}
private static int[] findFirstVisibleItemPositions(@NonNull RecyclerView.LayoutManager layoutManager) {
@ -128,6 +127,31 @@ final class GiphyMp4AdapterPlaybackController extends RecyclerView.OnScrollListe
}
interface Callback {
void update(@NonNull List<GiphyMp4ViewHolder> holders, @NonNull GiphyMp4PlaybackRange range);
void update(@NonNull RecyclerView recyclerView, @NonNull List<GiphyMp4Playable> holders, @NonNull Set<Integer> playbackSet);
}
@VisibleForTesting
static final class RangeComparator implements Comparator<Integer> {
private final int center;
RangeComparator(int firstVisiblePosition, int lastVisiblePosition) {
int delta = lastVisiblePosition - firstVisiblePosition;
center = firstVisiblePosition + (delta / 2);
}
@Override
public int compare(Integer o1, Integer o2) {
int distance1 = Math.abs(o1 - center);
int distance2 = Math.abs(o2 - center);
int comp = Integer.compare(distance1, distance2);
if (comp == 0) {
return Integer.compare(o1, o2);
}
return comp;
}
}
}

View file

@ -4,11 +4,9 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecInfo;
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
import com.google.android.exoplayer2.util.MimeTypes;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.concurrent.TimeUnit;
@ -17,28 +15,44 @@ import java.util.concurrent.TimeUnit;
*/
public final class GiphyMp4PlaybackPolicy {
private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 = 6;
private static final int MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM = 3;
private static final float SEARCH_RESULT_RATIO = 0.75f;
private GiphyMp4PlaybackPolicy() { }
public static boolean sendAsMp4() {
return FeatureFlags.mp4GifSendSupport();
}
public static boolean autoplay() {
return !DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication());
}
public static int maxRepeatsOfSinglePlayback() {
return 3;
return 4;
}
public static long maxDurationOfSinglePlayback() {
return TimeUnit.SECONDS.toMillis(6);
return TimeUnit.SECONDS.toMillis(8);
}
public static int maxSimultaneousPlaybackInConversation() {
return maxSimultaneousPlaybackWithRatio(1f - SEARCH_RESULT_RATIO);
}
public static int maxSimultaneousPlaybackInSearchResults() {
return maxSimultaneousPlaybackWithRatio(SEARCH_RESULT_RATIO);
}
private static int maxSimultaneousPlaybackWithRatio(float ratio) {
int maxInstances = 0;
try {
MediaCodecInfo info = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false);
if (info != null) {
maxInstances = (int) (info.getMaxSupportedInstances() * 0.75f);
if (info != null && info.getMaxSupportedInstances() > 0) {
maxInstances = (int) (info.getMaxSupportedInstances() * ratio);
}
} catch (MediaCodecUtil.DecoderQueryException ignored) {
@ -49,9 +63,9 @@ public final class GiphyMp4PlaybackPolicy {
}
if (DeviceProperties.isLowMemoryDevice(ApplicationDependencies.getApplication())) {
return 2;
return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23_LOW_MEM * ratio);
} else {
return 6;
return (int) (MAXIMUM_SUPPORTED_PLAYBACK_PRE_23 * ratio);
}
}
}

View file

@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.giph.mp4;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
/**
* Enforces a video player to play back a specified number of loops given
* video length and device policy.
*/
public final class GiphyMp4PlaybackPolicyEnforcer {
private final Callback callback;
private final long maxDurationOfSinglePlayback;
private final long maxRepeatsOfSinglePlayback;
private long loopsRemaining = -1;
public GiphyMp4PlaybackPolicyEnforcer(@NonNull Callback callback) {
this(callback,
GiphyMp4PlaybackPolicy.maxDurationOfSinglePlayback(),
GiphyMp4PlaybackPolicy.maxRepeatsOfSinglePlayback());
}
@VisibleForTesting
GiphyMp4PlaybackPolicyEnforcer(@NonNull Callback callback,
long maxDurationOfSinglePlayback,
long maxRepeatsOfSinglePlayback)
{
this.callback = callback;
this.maxDurationOfSinglePlayback = maxDurationOfSinglePlayback;
this.maxRepeatsOfSinglePlayback = maxRepeatsOfSinglePlayback;
}
void setMediaDuration(long duration) {
long maxLoopsByDuration = Math.max(1, maxDurationOfSinglePlayback / duration);
loopsRemaining = Math.min(maxLoopsByDuration, maxRepeatsOfSinglePlayback);
}
public boolean endPlayback() {
if (loopsRemaining < 0) throw new IllegalStateException("Must call setMediaDuration before calling this method.");
else if (loopsRemaining == 0) return true;
else {
loopsRemaining--;
if (loopsRemaining == 0) {
callback.onPlaybackWillEnd();
return true;
} else {
return false;
}
}
}
public interface Callback {
void onPlaybackWillEnd();
}
}

View file

@ -1,46 +0,0 @@
package org.thoughtcrime.securesms.giph.mp4;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.Objects;
/**
* Object describing the range of adapter positions for which playback should begin.
*/
final class GiphyMp4PlaybackRange {
private final int startPosition;
private final int endPosition;
GiphyMp4PlaybackRange(int startPosition, int endPosition) {
this.startPosition = startPosition;
this.endPosition = endPosition;
}
boolean shouldPlayVideo(int adapterPosition) {
if (adapterPosition == RecyclerView.NO_POSITION) return false;
return this.startPosition <= adapterPosition && this.endPosition > adapterPosition;
}
@Override
public @NonNull String toString() {
return "PlaybackRange{" +
"startPosition=" + startPosition +
", endPosition=" + endPosition +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final GiphyMp4PlaybackRange that = (GiphyMp4PlaybackRange) o;
return startPosition == that.startPosition &&
endPosition == that.endPosition;
}
@Override public int hashCode() {
return Objects.hash(startPosition, endPosition);
}
}

View file

@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.source.MediaSource;
/**
* Object which holds on to an injected video player.
*/
final class GiphyMp4PlayerHolder implements Player.EventListener {
private final FrameLayout container;
private final GiphyMp4VideoPlayer player;
private Runnable onPlaybackReady;
private MediaSource mediaSource;
GiphyMp4PlayerHolder(@NonNull FrameLayout container, @NonNull GiphyMp4VideoPlayer player) {
this.container = container;
this.player = player;
}
@NonNull FrameLayout getContainer() {
return container;
}
public void setMediaSource(@Nullable MediaSource mediaSource) {
this.mediaSource = mediaSource;
if (mediaSource != null) {
player.setVideoSource(mediaSource);
player.play();
} else {
player.stop();
}
}
public @Nullable MediaSource getMediaSource() {
return mediaSource;
}
void setOnPlaybackReady(@Nullable Runnable onPlaybackReady) {
this.onPlaybackReady = onPlaybackReady;
}
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
if (playbackState == Player.STATE_READY) {
if (onPlaybackReady != null) {
onPlaybackReady.run();
}
}
}
}

View file

@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewParent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.components.CornerMask;
/**
* Describes the position and size of the area where a video should play.
*/
public final class GiphyMp4Projection {
private final float x;
private final float y;
private final int width;
private final int height;
private final CornerMask cornerMask;
public GiphyMp4Projection(float x, float y, int width, int height, @Nullable CornerMask cornerMask) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.cornerMask = cornerMask;
}
public float getX() {
return x;
}
public float getY() {
return y;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public @Nullable CornerMask getCornerMask() {
return cornerMask;
}
public @NonNull GiphyMp4Projection translateX(float xTranslation) {
return new GiphyMp4Projection(x + xTranslation, y, width, height, cornerMask);
}
public static @NonNull GiphyMp4Projection forView(@NonNull RecyclerView recyclerView, @NonNull View view, @Nullable CornerMask cornerMask) {
Rect viewBounds = new Rect();
view.getDrawingRect(viewBounds);
recyclerView.offsetDescendantRectToMyCoords(view, viewBounds);
return new GiphyMp4Projection(viewBounds.left, viewBounds.top, view.getWidth(), view.getHeight(), cornerMask);
}
}

View file

@ -0,0 +1,117 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.Lifecycle;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import org.signal.glide.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.CornerMask;
import java.util.ArrayList;
import java.util.List;
/**
* Object which holds on to an injected video player.
*/
public final class GiphyMp4ProjectionPlayerHolder implements Player.EventListener {
private final FrameLayout container;
private final GiphyMp4VideoPlayer player;
private Runnable onPlaybackReady;
private MediaSource mediaSource;
private GiphyMp4PlaybackPolicyEnforcer policyEnforcer;
private GiphyMp4ProjectionPlayerHolder(@NonNull FrameLayout container, @NonNull GiphyMp4VideoPlayer player) {
this.container = container;
this.player = player;
}
@NonNull FrameLayout getContainer() {
return container;
}
public void playContent(@NonNull MediaSource mediaSource, @Nullable GiphyMp4PlaybackPolicyEnforcer policyEnforcer) {
this.mediaSource = mediaSource;
this.policyEnforcer = policyEnforcer;
player.setVideoSource(mediaSource);
player.play();
}
public void clearMedia() {
this.mediaSource = null;
this.policyEnforcer = null;
player.stop();
}
public @Nullable MediaSource getMediaSource() {
return mediaSource;
}
public void setOnPlaybackReady(@Nullable Runnable onPlaybackReady) {
this.onPlaybackReady = onPlaybackReady;
}
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
if (playbackState == Player.STATE_READY) {
if (onPlaybackReady != null) {
if (policyEnforcer != null) {
policyEnforcer.setMediaDuration(player.getDuration());
}
onPlaybackReady.run();
}
}
}
@Override
public void onPositionDiscontinuity(int reason) {
if (policyEnforcer != null && reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
if (policyEnforcer.endPlayback()) {
player.stop();
}
}
}
public static @NonNull List<GiphyMp4ProjectionPlayerHolder> injectVideoViews(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull ViewGroup viewGroup,
int nPlayers)
{
List<GiphyMp4ProjectionPlayerHolder> holders = new ArrayList<>(nPlayers);
GiphyMp4ExoPlayerProvider playerProvider = new GiphyMp4ExoPlayerProvider(context);
for (int i = 0; i < nPlayers; i++) {
FrameLayout container = (FrameLayout) LayoutInflater.from(context)
.inflate(R.layout.giphy_mp4_player, viewGroup, false);
GiphyMp4VideoPlayer player = container.findViewById(R.id.video_player);
ExoPlayer exoPlayer = playerProvider.create();
GiphyMp4ProjectionPlayerHolder holder = new GiphyMp4ProjectionPlayerHolder(container, player);
lifecycle.addObserver(player);
player.setExoPlayer(exoPlayer);
player.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL);
exoPlayer.addListener(holder);
holders.add(holder);
viewGroup.addView(container);
}
return holders;
}
public void setCornerMask(@Nullable CornerMask cornerMask) {
player.setCornerMask(cornerMask);
}
}

View file

@ -0,0 +1,137 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Logic for updating content and positioning of videos as the user scrolls the list of gifs.
*/
public final class GiphyMp4ProjectionRecycler implements GiphyMp4PlaybackController.Callback, GiphyMp4DisplayUpdater {
private final List<GiphyMp4ProjectionPlayerHolder> holders;
private final SparseArray<GiphyMp4ProjectionPlayerHolder> playing;
private final SparseArray<GiphyMp4ProjectionPlayerHolder> notPlaying;
public GiphyMp4ProjectionRecycler(@NonNull List<GiphyMp4ProjectionPlayerHolder> holders) {
this.holders = holders;
this.playing = new SparseArray<>(holders.size());
this.notPlaying = new SparseArray<>(holders.size());
}
@Override
public void update(@NonNull RecyclerView recyclerView,
@NonNull List<GiphyMp4Playable> holders,
@NonNull Set<Integer> playbackSet)
{
stopAndReleaseAssignedVideos(playbackSet);
for (final GiphyMp4Playable holder : holders) {
if (playbackSet.contains(holder.getAdapterPosition())) {
startPlayback(acquireHolderForPosition(holder.getAdapterPosition()), holder);
} else {
holder.showProjectionArea();
}
}
for (final GiphyMp4Playable holder : holders) {
updateDisplay(recyclerView, holder);
}
}
@Override
public void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4Playable holder) {
GiphyMp4ProjectionPlayerHolder playerHolder = getCurrentHolder(holder.getAdapterPosition());
if (playerHolder != null) {
updateDisplay(recyclerView, playerHolder, holder);
}
}
public @Nullable View getVideoPlayerAtAdapterPosition(int adapterPosition) {
GiphyMp4ProjectionPlayerHolder holder = getCurrentHolder(adapterPosition);
if (holder != null) return holder.getContainer();
else return null;
}
private void stopAndReleaseAssignedVideos(@NonNull Set<Integer> playbackSet) {
List<Integer> markedForDeletion = new ArrayList<>(playing.size());
for (int i = 0; i < playing.size(); i++) {
if (!playbackSet.contains(playing.keyAt(i))) {
notPlaying.put(playing.keyAt(i), playing.valueAt(i));
playing.valueAt(i).clearMedia();
playing.valueAt(i).setOnPlaybackReady(null);
markedForDeletion.add(playing.keyAt(i));
}
}
for (final Integer key : markedForDeletion) {
playing.remove(key);
}
}
private void updateDisplay(@NonNull RecyclerView recyclerView, @NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) {
GiphyMp4Projection projection = giphyMp4Playable.getProjection(recyclerView);
holder.getContainer().setX(projection.getX());
holder.getContainer().setY(projection.getY());
ViewGroup.LayoutParams params = holder.getContainer().getLayoutParams();
if (params.width != projection.getWidth() || params.height != projection.getHeight()) {
params.width = projection.getWidth();
params.height = projection.getHeight();
holder.getContainer().setLayoutParams(params);
}
holder.setCornerMask(projection.getCornerMask());
}
private void startPlayback(@NonNull GiphyMp4ProjectionPlayerHolder holder, @NonNull GiphyMp4Playable giphyMp4Playable) {
if (!Objects.equals(holder.getMediaSource(), giphyMp4Playable.getMediaSource())) {
holder.setOnPlaybackReady(null);
giphyMp4Playable.showProjectionArea();
holder.setOnPlaybackReady(giphyMp4Playable::hideProjectionArea);
holder.playContent(giphyMp4Playable.getMediaSource(), giphyMp4Playable.getPlaybackPolicyEnforcer());
}
}
private @Nullable GiphyMp4ProjectionPlayerHolder getCurrentHolder(int adapterPosition) {
if (playing.get(adapterPosition) != null) {
return playing.get(adapterPosition);
} else if (notPlaying.get(adapterPosition) != null) {
return notPlaying.get(adapterPosition);
} else {
return null;
}
}
private @NonNull GiphyMp4ProjectionPlayerHolder acquireHolderForPosition(int adapterPosition) {
GiphyMp4ProjectionPlayerHolder holder = playing.get(adapterPosition);
if (holder == null) {
if (notPlaying.size() != 0) {
holder = notPlaying.get(adapterPosition);
if (holder == null) {
int key = notPlaying.keyAt(0);
holder = Objects.requireNonNull(notPlaying.get(key));
notPlaying.remove(key);
} else {
notPlaying.remove(adapterPosition);
}
} else {
holder = holders.remove(0);
}
playing.put(adapterPosition, holder);
}
return holder;
}
}

View file

@ -1,14 +1,17 @@
package org.thoughtcrime.securesms.giph.mp4;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
@ -16,6 +19,7 @@ import com.google.android.exoplayer2.ui.PlayerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.CornerMask;
/**
* Video Player class specifically created for the GiphyMp4Fragment.
@ -27,6 +31,7 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
private final PlayerView exoView;
private ExoPlayer exoPlayer;
private CornerMask cornerMask;
public GiphyMp4VideoPlayer(Context context) {
this(context, null);
@ -49,7 +54,15 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
Log.d(TAG, "onDetachedFromWindow");
super.onDetachedFromWindow();
}
@Override protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (cornerMask != null) {
cornerMask.mask(canvas);
}
}
void setExoPlayer(@NonNull ExoPlayer exoPlayer) {
exoView.setPlayer(exoPlayer);
this.exoPlayer = exoPlayer;
@ -58,6 +71,11 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
void setVideoSource(@NonNull MediaSource mediaSource) {
exoPlayer.prepare(mediaSource);
}
void setCornerMask(@Nullable CornerMask cornerMask) {
this.cornerMask = new CornerMask(this, cornerMask);
invalidate();
}
void play() {
if (exoPlayer != null) {
@ -71,6 +89,14 @@ public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLif
}
}
long getDuration() {
if (exoPlayer != null) {
return exoPlayer.getDuration();
} else {
return C.LENGTH_UNSET;
}
}
void setResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) {
exoView.setResizeMode(resizeMode);
}

View file

@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.util.Util;
/**
* Holds a view which will either play back an MP4 gif or show its still.
*/
final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder {
final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
private final AspectRatioFrameLayout container;
private final ImageView stillImage;
@ -62,14 +62,31 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(v -> listener.onClick(giphyImage));
}
void show() {
@Override
public void showProjectionArea() {
container.setAlpha(1f);
}
void hide() {
@Override
public void hideProjectionArea() {
container.setAlpha(0f);
}
@Override
public @NonNull MediaSource getMediaSource() {
return mediaSource;
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerView) {
return GiphyMp4Projection.forView(recyclerView, itemView, null);
}
@Override
public boolean canPlayContent() {
return true;
}
private void loadPlaceholderImage(@NonNull GiphyImage giphyImage) {
GlideApp.with(itemView)
.load(new ChunkedImageUrl(giphyImage.getStillUrl()))
@ -78,8 +95,4 @@ final class GiphyMp4ViewHolder extends RecyclerView.ViewHolder {
.transition(DrawableTransitionOptions.withCrossFade())
.into(stillImage);
}
@NonNull MediaSource getMediaSource() {
return mediaSource;
}
}

View file

@ -5,6 +5,7 @@ import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.MenuItem;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.lifecycle.ViewModelProviders;
@ -15,6 +16,9 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsViewModel.Factory;
import org.thoughtcrime.securesms.mms.GlideApp;
@ -65,6 +69,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
initializeList();
initializeViewModel();
initializeActionBar();
initializeVideoPlayer();
}
@Override
@ -113,6 +118,15 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
});
}
private void initializeVideoPlayer() {
FrameLayout videoContainer = findViewById(R.id.video_container);
RecyclerView recyclerView = findViewById(R.id.message_details_list);
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(this, getLifecycle(), videoContainer, 1);
GiphyMp4ProjectionRecycler callback = new GiphyMp4ProjectionRecycler(holders);
GiphyMp4PlaybackController.attach(recyclerView, callback, 1);
}
private void initializeActionBar() {
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
requireSupportActionBar().setTitle(R.string.AndroidManifest__message_details);

View file

@ -3,7 +3,11 @@ package org.thoughtcrime.securesms.messagedetails;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.TextView;
@ -12,16 +16,24 @@ import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.source.MediaSource;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.MaskDrawable;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.sql.Date;
@ -29,7 +41,7 @@ import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.Locale;
final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable {
private final TextView sentDate;
private final TextView receivedDate;
private final TextView expiresIn;
@ -42,6 +54,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
private final ViewStub updateStub;
private final ViewStub sentStub;
private final ViewStub receivedStub;
private final MaskDrawable maskDrawable;
private GlideRequests glideRequests;
private ConversationItem conversationItem;
@ -63,6 +76,9 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
updateStub = itemView.findViewById(R.id.message_details_header_message_view_update);
sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia);
receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia);
maskDrawable = new MaskDrawable(itemView.getBackground());
itemView.setBackground(maskDrawable);
}
void bind(@NonNull LifecycleOwner lifecycleOwner, @Nullable ConversationMessage conversationMessage, boolean running) {
@ -88,7 +104,20 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
conversationItem = (ConversationItem) receivedStub.inflate();
}
}
conversationItem.bind(lifecycleOwner, conversationMessage, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), conversationMessage.getMessageRecord().getRecipient(), null, false, false, false);
conversationItem.bind(lifecycleOwner,
conversationMessage,
Optional.absent(),
Optional.absent(),
glideRequests,
Locale.getDefault(),
new HashSet<>(),
conversationMessage.getMessageRecord().getRecipient(),
null,
false,
false,
false,
new AttachmentMediaSourceFactory(conversationItem.getContext()),
true);
}
private void bindErrorState(MessageRecord messageRecord) {
@ -181,6 +210,39 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
((ClipboardManager) itemView.getContext().getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", text));
}
@Override
public void showProjectionArea() {
conversationItem.showProjectionArea();
maskDrawable.setMask(null);
}
@Override
public void hideProjectionArea() {
conversationItem.hideProjectionArea();
maskDrawable.setMask(conversationItem.getThumbnailMaskingRect((ViewGroup) itemView));
maskDrawable.setCorners(conversationItem.getThumbnailCornerMask(itemView).getRadii());
}
@Override
public @Nullable MediaSource getMediaSource() {
return conversationItem.getMediaSource();
}
@Override
public @Nullable GiphyMp4PlaybackPolicyEnforcer getPlaybackPolicyEnforcer() {
return conversationItem.getPlaybackPolicyEnforcer();
}
@Override
public @NonNull GiphyMp4Projection getProjection(@NonNull RecyclerView recyclerview) {
return conversationItem.getProjection(recyclerview);
}
@Override public
boolean canPlayContent() {
return conversationItem.canPlayContent();
}
private class ExpiresUpdater implements Runnable {
private final long expireStartedTimestamp;

View file

@ -22,7 +22,7 @@ public class GifSlide extends ImageSlide {
}
public GifSlide(Context context, Uri uri, long size, int width, int height, boolean borderless, @Nullable String caption) {
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, null, null, null, false, borderless, false, false));
super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, null, null, null, false, borderless, true, false));
this.borderless = borderless;
}
@ -30,4 +30,9 @@ public class GifSlide extends ImageSlide {
public boolean isBorderless() {
return borderless;
}
@Override
public boolean isVideoGif() {
return true;
}
}

View file

@ -1,9 +1,10 @@
package org.thoughtcrime.securesms.components.voice;
package org.thoughtcrime.securesms.video.exo;
import android.content.Context;
import android.net.Uri;
import android.support.v4.media.MediaDescriptionCompat;
import androidx.annotation.Nullable;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
import com.google.android.exoplayer2.extractor.ExtractorsFactory;
@ -11,17 +12,20 @@ import com.google.android.exoplayer2.source.ExtractorMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory;
/**
* This class is responsible for creating a MediaSource object for a given MediaDescriptionCompat
* This class is responsible for creating a MediaSource object for a given Uri, using AttachmentDataSourceFactory
*/
final class VoiceNoteMediaSourceFactory {
public final class AttachmentMediaSourceFactory {
private final Context context;
private final ExtractorMediaSource.Factory extractorMediaSourceFactory;
VoiceNoteMediaSourceFactory(Context context) {
this.context = context;
public AttachmentMediaSourceFactory(@NonNull Context context) {
DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null);
AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null);
ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true);
extractorMediaSourceFactory = new ExtractorMediaSource.Factory(attachmentDataSourceFactory)
.setExtractorsFactory(extractorsFactory);
}
/**
@ -31,13 +35,14 @@ final class VoiceNoteMediaSourceFactory {
*
* @return A preparable MediaSource
*/
public @Nullable MediaSource createMediaSource(MediaDescriptionCompat description) {
DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null);
AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null);
ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true);
public @NonNull MediaSource createMediaSource(MediaDescriptionCompat description) {
return createMediaSource(description.getMediaUri());
}
return new ExtractorMediaSource.Factory(attachmentDataSourceFactory)
.setExtractorsFactory(extractorsFactory)
.createMediaSource(description.getMediaUri());
/**
* Creates a MediaSource for a given Uri
*/
public @NonNull MediaSource createMediaSource(Uri uri) {
return extractorMediaSourceFactory.createMediaSource(uri);
}
}

View file

@ -11,6 +11,15 @@
layout="@layout/conversation_item_banner"
android:visibility="gone" />
<FrameLayout
android:id="@+id/video_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@android:id/list"
app:layout_constraintEnd_toEndOf="@android:id/list"
app:layout_constraintStart_toStartOf="@android:id/list"
app:layout_constraintTop_toTopOf="@android:id/list" />
<androidx.recyclerview.widget.RecyclerView
android:id="@android:id/list"
android:layout_width="match_parent"

View file

@ -15,5 +15,6 @@
app:conversationThumbnail_maxWidth="@dimen/media_bubble_max_width"
app:conversationThumbnail_minHeight="@dimen/media_bubble_min_height"
app:conversationThumbnail_maxHeight="@dimen/media_bubble_max_height"
app:conversationThumbnail_gifWidth="@dimen/media_bubble_gif_width"
tools:src="@drawable/ic_video_light"
tools:visibility="gone" />

View file

@ -17,5 +17,6 @@
app:conversationThumbnail_maxWidth="@dimen/media_bubble_max_width"
app:conversationThumbnail_minHeight="@dimen/media_bubble_min_height"
app:conversationThumbnail_maxHeight="@dimen/media_bubble_max_height"
app:conversationThumbnail_gifWidth="@dimen/media_bubble_gif_width"
tools:src="@drawable/ic_video_light"
tools:visibility="visible" />

View file

@ -1,8 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/message_details_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preference_divider"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
android:background="@drawable/preference_divider">
<FrameLayout
android:id="@+id/video_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/message_details_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</FrameLayout>

View file

@ -14,4 +14,6 @@
<dimen name="payment_recovery_phrase_adapter_margin">92dp</dimen>
<dimen name="payment_recovery_phrase_outline_margin">48dp</dimen>
<dimen name="media_bubble_gif_width">260dp</dimen>
</resources>

View file

@ -173,6 +173,7 @@
<attr name="conversationThumbnail_maxWidth" format="dimension" />
<attr name="conversationThumbnail_minHeight" format="dimension" />
<attr name="conversationThumbnail_maxHeight" format="dimension" />
<attr name="conversationThumbnail_gifWidth" format="dimension" />
</declare-styleable>
<declare-styleable name="TypingIndicatorView">

View file

@ -47,6 +47,7 @@
<dimen name="media_bubble_min_height">100dp</dimen>
<dimen name="media_bubble_max_height">320dp</dimen>
<dimen name="media_bubble_sticker_dimens">175dp</dimen>
<dimen name="media_bubble_gif_width">240dp</dimen>
<dimen name="message_audio_width">242dp</dimen>
<dimen name="media_picker_folder_width">175dp</dimen>

View file

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.giph.mp4
import org.junit.Assert
import org.junit.Test
class GiphyMp4PlaybackControllerRangeComparatorTest {
@Test
fun `Given a range of numbers, when I sort with comparator, then I expect an array sorted from the center out`() {
val testSubject = createComparator(0, 10)
val sorted = (0..10).sortedWith(testSubject).toIntArray()
val expected = intArrayOf(5, 4, 6, 3, 7, 2, 8, 1, 9, 0, 10)
Assert.assertArrayEquals(expected, sorted)
}
private fun createComparator(start: Int, end: Int): GiphyMp4PlaybackController.RangeComparator =
GiphyMp4PlaybackController.RangeComparator(start, end)
}

View file

@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.giph.mp4
import org.junit.Assert
import org.junit.Test
import java.util.concurrent.TimeUnit
class GiphyMp4PlaybackPolicyEnforcerTest {
@Test
fun `Given a 1s video, when I have a max time of 8s and max repeats of 4, then I expect 4 loops`() {
val mediaDuration = TimeUnit.SECONDS.toMillis(1)
val maxDuration = TimeUnit.SECONDS.toMillis(8)
val maxRepeats = 4L
var ended = false
val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats)
testSubject.setMediaDuration(mediaDuration)
Assert.assertTrue((0..2).map { testSubject.endPlayback() }.all { !it })
Assert.assertFalse(ended)
Assert.assertTrue(testSubject.endPlayback())
Assert.assertTrue(ended)
}
@Test
fun `Given a 3s video, when I have a max time of 8s and max repeats of 4, then I expect 2 loops`() {
val mediaDuration = TimeUnit.SECONDS.toMillis(3)
val maxDuration = TimeUnit.SECONDS.toMillis(8)
val maxRepeats = 4L
var ended = false
val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats)
testSubject.setMediaDuration(mediaDuration)
Assert.assertFalse(testSubject.endPlayback())
Assert.assertFalse(ended)
Assert.assertTrue(testSubject.endPlayback())
Assert.assertTrue(ended)
}
@Test
fun `Given a 10s video, when I have a max time of 8s and max repeats of 4, then I expect 1 loop`() {
val mediaDuration = TimeUnit.SECONDS.toMillis(10)
val maxDuration = TimeUnit.SECONDS.toMillis(8)
val maxRepeats = 4L
var ended = false
val testSubject = GiphyMp4PlaybackPolicyEnforcer({ ended = true }, maxDuration, maxRepeats)
testSubject.setMediaDuration(mediaDuration)
Assert.assertTrue(testSubject.endPlayback())
Assert.assertTrue(ended)
}
}