diff --git a/app/build.gradle b/app/build.gradle index b36544fb9d..6723dfeddb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,8 +143,8 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.2' } -def canonicalVersionCode = 182 -def canonicalVersionName = "1.10.13" +def canonicalVersionCode = 188 +def canonicalVersionName = "1.11.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -194,7 +194,7 @@ android { versionCode canonicalVersionCode * postFixSize versionName canonicalVersionName - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 30 multiDexEnabled = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 34321f8552..32869dd1f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - @@ -7,7 +8,7 @@ - - - - - - - + - + + @@ -90,22 +86,16 @@ android:name="firebase_messaging_auto_init_enabled" android:value="false" /> - + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> - @@ -113,16 +103,16 @@ android:name="org.thoughtcrime.securesms.loki.activities.LinkDeviceActivity" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize" - android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> + android:label="@string/activity_settings_title" /> + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> + android:windowSoftInputMode="adjustResize" + android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> @@ -164,14 +155,14 @@ + android:screenOrientation="portrait" /> - + - - @@ -195,7 +184,6 @@ - @@ -207,11 +195,9 @@ android:targetActivity="org.thoughtcrime.securesms.loki.activities.HomeActivity"> - - @@ -232,14 +218,18 @@ android:name="android.support.PARENT_ACTIVITY" android:value="org.thoughtcrime.securesms.loki.activities.HomeActivity" /> + + android:theme="@style/Theme.TextSecure.DayNight" /> + android:theme="@style/Theme.TextSecure.DayNight" /> + android:theme="@style/Theme.Session.DayNight.NoActionBar" /> + android:theme="@style/Theme.Session.ForceDark" /> - @@ -444,7 +433,6 @@ - - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index 534127240f..aad4c17008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -26,7 +26,7 @@ import android.widget.TextView; import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; -import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.mms.GlideRequests; diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 3f8f03fa51..0b36a73eed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -64,10 +64,12 @@ import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.MediaView; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel; import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.DateUtils; @@ -116,6 +118,22 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im private int restartItem = -1; + public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) { + Intent previewIntent = null; + if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { + previewIntent = new Intent(context, MediaPreviewActivity.class); + previewIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(slide.getUri(), slide.getContentType()) + .putExtra(ADDRESS_EXTRA, threadRecipient.getAddress()) + .putExtra(OUTGOING_EXTRA, mms.isOutgoing()) + .putExtra(DATE_EXTRA, mms.getTimestamp()) + .putExtra(SIZE_EXTRA, slide.asAttachment().getSize()) + .putExtra(CAPTION_EXTRA, slide.getCaption().orNull()) + .putExtra(LEFT_IS_RECENT_EXTRA, false); + } + return previewIntent; + } + @SuppressWarnings("ConstantConditions") @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsActivity.java index 314653743c..27526e9485 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsActivity.java @@ -187,8 +187,6 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity transportText = "-"; } else if (messageRecord.isPending()) { transportText = getString(R.string.ConversationFragment_pending); - } else if (messageRecord.isPush()) { - transportText = getString(R.string.ConversationFragment_push); } else if (messageRecord.isMms()) { transportText = getString(R.string.ConversationFragment_mms); } else { @@ -252,9 +250,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity private void updateRecipients(MessageRecord messageRecord, Recipient recipient, List recipients) { final int toFromRes; - if (messageRecord.isMms() && !messageRecord.isPush() && !messageRecord.isOutgoing()) { - toFromRes = R.string.message_details_header__with; - } else if (messageRecord.isOutgoing()) { + if (messageRecord.isOutgoing()) { toFromRes = R.string.message_details_header__to; } else { toFromRes = R.string.message_details_header__from; @@ -272,9 +268,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity private void inflateMessageViewIfAbsent(MessageRecord messageRecord) { if (conversationItem == null) { - if (messageRecord.isGroupAction()) { - conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_update, itemParent, false); - } else if (messageRecord.isOutgoing()) { + if (messageRecord.isOutgoing()) { conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent, itemParent, false); } else { conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received, itemParent, false); @@ -362,7 +356,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity List recipients = new LinkedList<>(); if (!messageRecord.getRecipient().isGroupRecipient()) { - recipients.add(new RecipientDeliveryStatus(messageRecord.getRecipient(), getStatusFor(messageRecord.getDeliveryReceiptCount(), messageRecord.getReadReceiptCount(), messageRecord.isPending()), messageRecord.isUnidentified(), -1)); + recipients.add(new RecipientDeliveryStatus(messageRecord.getRecipient(), getStatusFor(messageRecord.getDeliveryReceiptCount(), messageRecord.getReadReceiptCount(), messageRecord.isPending()), true, -1)); } else { List receiptInfoList = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageRecord.getId()); @@ -396,7 +390,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity updateRecipients(messageRecord, messageRecord.getRecipient(), recipients); boolean isGroupNetworkFailure = messageRecord.isFailed() && !messageRecord.getNetworkFailures().isEmpty(); - boolean isIndividualNetworkFailure = messageRecord.isFailed() && !isPushGroup && messageRecord.getIdentityKeyMismatches().isEmpty(); + boolean isIndividualNetworkFailure = messageRecord.isFailed() && !isPushGroup; LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(getContext()); String errorMessage = lokiMessageDatabase.getErrorMessage(messageRecord.id); diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java index a4a17b7389..8f820cba54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java @@ -39,6 +39,7 @@ import org.session.libsession.utilities.DistributionTypes; import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.session.libsession.utilities.Address; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.loki.fragments.ContactSelectionListFragment; @@ -215,9 +216,9 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity } private void createConversation(long threadId, Address address, int distributionType) { - final Intent intent = getBaseShareIntent(ConversationActivity.class); - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, address); - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); + final Intent intent = getBaseShareIntent(ConversationActivityV2.class); + intent.putExtra(ConversationActivityV2.ADDRESS, address); + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType); isPassingAlongMedia = true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index a7622306ba..61a92105aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; @@ -103,9 +104,9 @@ public class AudioSlidePlayer implements SensorEventListener { } private void play(final double progress, boolean earpiece) throws IOException { - if (this.mediaPlayer != null) return; + if (this.mediaPlayer != null) { stop(); } - LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl(); + LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl(); this.mediaPlayer = ExoPlayerFactory.newSimpleInstance(context, new DefaultRenderersFactory(context), new DefaultTrackSelector(), loadControl); this.audioAttachmentServer = new AttachmentServer(context, slide.asAttachment()); this.startTime = System.currentTimeMillis(); @@ -184,8 +185,6 @@ public class AudioSlidePlayer implements SensorEventListener { public void onPlayerError(ExoPlaybackException error) { Log.w(TAG, "MediaPlayer Error: " + error); - Toast.makeText(context, R.string.AudioSlidePlayer_error_playing_audio, Toast.LENGTH_SHORT).show(); - synchronized (AudioSlidePlayer.this) { mediaPlayer = null; @@ -267,8 +266,17 @@ public class AudioSlidePlayer implements SensorEventListener { return slide; } + public Long getDuration() { + if (mediaPlayer == null) { return 0L; } + return mediaPlayer.getDuration(); + } - private Pair getProgress() { + public Double getProgress() { + if (mediaPlayer == null) { return 0.0; } + return (double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(); + } + + private Pair getProgressTuple() { if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { return new Pair<>(0D, 0); } else { @@ -277,6 +285,16 @@ public class AudioSlidePlayer implements SensorEventListener { } } + public float getPlaybackSpeed() { + if (mediaPlayer == null) { return 1.0f; } + return mediaPlayer.getPlaybackParameters().speed; + } + + public void setPlaybackSpeed(float speed) { + if (mediaPlayer == null) { return; } + mediaPlayer.setPlaybackParameters(new PlaybackParameters(speed)); + } + private void notifyOnStart() { Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this)); } @@ -383,7 +401,7 @@ public class AudioSlidePlayer implements SensorEventListener { return; } - Pair progress = player.getProgress(); + Pair progress = player.getProgressTuple(); player.notifyOnProgress(progress.first, progress.second); sendEmptyMessageDelayed(0, 50); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java index 9f472fb069..fde2cd6b52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlbumThumbnailView.java @@ -1,25 +1,27 @@ package org.thoughtcrime.securesms.components; import android.content.Context; -import androidx.annotation.ColorInt; -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.AttributeSet; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.TextView; -import network.loki.messenger.R; -import org.thoughtcrime.securesms.mms.GlideRequests; +import androidx.annotation.ColorInt; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.session.libsession.utilities.Stub; +import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView; +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.session.libsession.utilities.Stub; import java.util.List; +import network.loki.messenger.R; + public class AlbumThumbnailView extends FrameLayout { private @Nullable SlideClickListener thumbnailClickListener; @@ -51,8 +53,8 @@ public class AlbumThumbnailView extends FrameLayout { private void initialize() { inflate(getContext(), R.layout.album_thumbnail_view, this); - albumCellContainer = findViewById(R.id.album_cell_container); - transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub)); + albumCellContainer = findViewById(R.id.albumCellContainer); + transferControls = new Stub<>(findViewById(R.id.albumTransferControlsStub)); } public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List slides, boolean showControls) { @@ -147,10 +149,5 @@ public class AlbumThumbnailView extends FrameLayout { } private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) { - ThumbnailView cell = findViewById(id); - cell.setImageResource(glideRequests, slide, false, false); - cell.setLoadIndicatorVisibile(slide.isInProgress()); - cell.setThumbnailClickListener(defaultThumbnailClickListener); - cell.setOnLongClickListener(defaultLongClickListener); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 2715d9b773..b93ae4ab65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -4,15 +4,17 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.service.ExpiringMessageManager; @@ -88,8 +90,6 @@ public class ConversationItemFooter extends LinearLayout { if (messageRecord.isFailed()) { dateView.setText(R.string.ConversationItem_error_not_delivered); - } else if (messageRecord.isPendingInsecureSmsFallback()) { - dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted); } else { dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); } @@ -131,14 +131,14 @@ public class ConversationItemFooter extends LinearLayout { } private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) { - insecureIndicatorView.setVisibility(messageRecord.isSecure() ? View.GONE : View.VISIBLE); + insecureIndicatorView.setVisibility(View.GONE); } private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) { - if (!messageRecord.isFailed() && !messageRecord.isPendingInsecureSmsFallback()) { + if (!messageRecord.isFailed()) { if (!messageRecord.isOutgoing()) deliveryStatusView.setNone(); else if (messageRecord.isPending()) deliveryStatusView.setPending(); - else if (messageRecord.isRemoteRead()) deliveryStatusView.setRead(); + else if (messageRecord.isRead()) deliveryStatusView.setRead(); else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered(); else deliveryStatusView.setSent(); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java index 379b5c77a7..af9e766416 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemThumbnail.java @@ -12,6 +12,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; @@ -27,7 +28,7 @@ import network.loki.messenger.R; public class ConversationItemThumbnail extends FrameLayout { - private ThumbnailView thumbnail; + private ThumbnailView thumbnail; private AlbumThumbnailView album; private ImageView shade; private ConversationItemFooter footer; @@ -64,15 +65,10 @@ public class ConversationItemThumbnail extends FrameLayout { if (attrs != null) { TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0); - thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0), - typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)); typedArray.recycle(); } } - @SuppressWarnings("SuspiciousNameCombination") @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java index 4246a79f37..ddc782628a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationTypingView.java @@ -8,6 +8,7 @@ import android.util.AttributeSet; import android.view.View; import android.widget.LinearLayout; +import org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.ThemeUtil; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java index 228ed97e46..71bf8a2804 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java @@ -5,6 +5,7 @@ import android.graphics.Canvas; import android.util.AttributeSet; import org.session.libsession.utilities.ThemeUtil; +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import network.loki.messenger.R; @@ -28,7 +29,6 @@ public class OutlinedThumbnailView extends ThumbnailView { outliner = new Outliner(); outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color)); - setRadius(0); setWillNotDraw(false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java index 3b8c30d31b..6214c58531 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java @@ -7,6 +7,7 @@ import android.util.AttributeSet; import android.view.View; import android.widget.FrameLayout; +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index e3317ff1cd..027a319651 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -79,8 +79,7 @@ public class TypingStatusSender { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); Recipient recipient = threadDatabase.getRecipientForThreadId(threadId); if (recipient == null) { return; } - // Loki - Check whether we want to send a typing indicator to this user - if (recipient != null && !SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; } + if (!SessionMetaProtocol.shouldSendTypingIndicator(recipient.getAddress())) { return; } TypingIndicator typingIndicator; if (typingStarted) { typingIndicator = new TypingIndicator(TypingIndicator.Kind.STARTED); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index 28c8deced0..be730f275d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -11,7 +11,6 @@ import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.AttributeSet; import android.util.TypedValue; - import network.loki.messenger.R; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; @@ -19,9 +18,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; import org.session.libsignal.utilities.guava.Optional; - public class EmojiTextView extends AppCompatTextView { - private final boolean scaleEmojis; private static final char ELLIPSIS = '…'; @@ -46,14 +43,9 @@ public class EmojiTextView extends AppCompatTextView { public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); - TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); - scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); - maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); - a.recycle(); - - a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize}); - originalFontSize = a.getDimensionPixelSize(0, 0); - a.recycle(); + scaleEmojis = true; + maxLength = 1000; + originalFontSize = getResources().getDimension(R.dimen.small_font_size); } @Override public void setText(@Nullable CharSequence text, BufferType type) { @@ -182,8 +174,11 @@ public class EmojiTextView extends AppCompatTextView { @Override public void invalidateDrawable(@NonNull Drawable drawable) { - if (drawable instanceof EmojiDrawable) invalidate(); - else super.invalidateDrawable(drawable); + if (drawable instanceof EmojiDrawable) { + invalidate(); + } else { + super.invalidateDrawable(drawable); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index c1f46a6bcf..d6db6ec16d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -102,7 +102,6 @@ import org.session.libsession.utilities.recipients.RecipientModifiedListener; import org.session.libsession.utilities.ExpirationUtil; import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; @@ -165,8 +164,8 @@ import org.thoughtcrime.securesms.loki.views.MentionCandidateSelectionView; import org.thoughtcrime.securesms.loki.views.ProfilePictureView; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; -import org.thoughtcrime.securesms.mms.AttachmentManager; -import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager; +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager.MediaType; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.GifSlide; import org.thoughtcrime.securesms.mms.GlideApp; @@ -422,9 +421,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return; } - if (!org.thoughtcrime.securesms.util.Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent()) { + if (!org.thoughtcrime.securesms.util.Util.isEmpty(composeText)) { saveDraft(); - attachmentManager.clear(glideRequests, false); + attachmentManager.clear(); silentlySetComposeText(""); } @@ -1424,9 +1423,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity case AttachmentTypeSelector.ADD_SOUND: AttachmentManager.selectAudio(this, PICK_AUDIO); break; case AttachmentTypeSelector.ADD_CONTACT_INFO: - AttachmentManager.selectContactInfo(this, PICK_CONTACT); break; + break; case AttachmentTypeSelector.ADD_LOCATION: - AttachmentManager.selectLocation(this, PICK_LOCATION); break; + break; case AttachmentTypeSelector.TAKE_PHOTO: attachmentManager.capturePhoto(this, TAKE_PHOTO); break; case AttachmentTypeSelector.ADD_GIF: @@ -1620,7 +1619,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private String getMessage() throws InvalidMessageException { String result = composeText.getTextTrimmed(); - if (result.length() < 1 && !attachmentManager.isAttachmentPresent()) throw new InvalidMessageException(); + if (result.length() < 1) throw new InvalidMessageException(); for (Mention mention : mentions) { try { int startIndex = result.indexOf("@" + mention.getDisplayName()); @@ -1723,7 +1722,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity String message = getMessage(); boolean initiating = threadId == -1; boolean needsSplit = message.length() > characterCalculator.calculateCharacters(message).maxPrimaryMessageSize; - boolean isMediaMessage = attachmentManager.isAttachmentPresent() || + boolean isMediaMessage = false || // recipient.isGroupRecipient() || inputPanel.getQuote().isPresent() || linkPreviewViewModel.hasLinkPreview() || @@ -1785,7 +1784,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity ApplicationContext.getInstance(context).getTypingStatusSender().onTypingStopped(threadId); inputPanel.clearQuote(); - attachmentManager.clear(glideRequests, false); + attachmentManager.clear(); silentlySetComposeText(""); final long id = fragment.stageOutgoingMessage(outgoingMessage); @@ -1859,7 +1858,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return; } - if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) { + if (composeText.getText().length() == 0) { buttonToggle.display(attachButton); quickAttachmentToggle.show(); inlineAttachmentToggle.hide(); @@ -1867,7 +1866,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity buttonToggle.display(sendButton); quickAttachmentToggle.hide(); - if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreview()) { + if (!linkPreviewViewModel.hasLinkPreview()) { inlineAttachmentToggle.show(); } else { inlineAttachmentToggle.hide(); @@ -1876,7 +1875,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void updateLinkPreviewState() { - if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !attachmentManager.isAttachmentPresent()) { + if (TextSecurePreferences.isLinkPreviewsEnabled(this)) { linkPreviewViewModel.onEnabled(); linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), composeText.getSelectionStart(), composeText.getSelectionEnd()); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2e4dee6a38..74a3b5eddb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -351,10 +351,7 @@ public class ConversationFragment extends Fragment } for (MessageRecord messageRecord : messageRecords) { - if (messageRecord.isGroupAction() || messageRecord.isCallLog() || - messageRecord.isJoined() || messageRecord.isExpirationTimerUpdate() || - messageRecord.isEndSession() || messageRecord.isIdentityUpdate() || - messageRecord.isIdentityVerified() || messageRecord.isIdentityDefault() || messageRecord.isLokiSessionRestoreSent() || messageRecord.isLokiSessionRestoreDone()) + if (messageRecord.isCallLog() || messageRecord.isExpirationTimerUpdate()) { actionMessage = true; } @@ -385,8 +382,7 @@ public class ConversationFragment extends Fragment menu.findItem(R.id.menu_context_reply).setVisible(!actionMessage && !messageRecord.isPending() && - !messageRecord.isFailed() && - messageRecord.isSecure()); + !messageRecord.isFailed()); } menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText); @@ -626,7 +622,7 @@ public class ConversationFragment extends Fragment intent.putExtra(MessageDetailsActivity.THREAD_ID_EXTRA, threadId); intent.putExtra(MessageDetailsActivity.TYPE_EXTRA, message.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT); intent.putExtra(MessageDetailsActivity.ADDRESS_EXTRA, recipient.getAddress()); - intent.putExtra(MessageDetailsActivity.IS_PUSH_GROUP_EXTRA, recipient.isGroupRecipient() && message.isPush()); + intent.putExtra(MessageDetailsActivity.IS_PUSH_GROUP_EXTRA, recipient.isGroupRecipient()); startActivity(intent); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 344dfc93ee..aa60603074 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -784,12 +784,10 @@ public class ConversationItem extends LinearLayout } private void setStatusIcons(MessageRecord messageRecord) { - bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0); + bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); if (messageRecord.isFailed()) { alertView.setFailed(); - } else if (messageRecord.isPendingInsecureSmsFallback()) { - alertView.setPendingApproval(); } else { alertView.setNone(); } @@ -859,7 +857,7 @@ public class ConversationItem extends LinearLayout boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp()); - if (current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() || + if (current.getExpiresIn() > 0 || current.isPending() || current.isFailed() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread)) { ConversationItemFooter activeFooter = getActiveFooter(current); @@ -881,10 +879,7 @@ public class ConversationItem extends LinearLayout } private boolean shouldInterceptClicks(MessageRecord messageRecord) { - return batchSelected.isEmpty() && - ((messageRecord.isFailed() && !messageRecord.isMmsNotification()) || - messageRecord.isPendingInsecureSmsFallback() || - messageRecord.isBundleKeyExchange()); + return batchSelected.isEmpty() && (messageRecord.isFailed() && !messageRecord.isMmsNotification()); } @SuppressLint("SetTextI18n") @@ -1199,7 +1194,7 @@ public class ConversationItem extends LinearLayout intent.putExtra(MessageDetailsActivity.MESSAGE_ID_EXTRA, messageRecord.getId()); intent.putExtra(MessageDetailsActivity.THREAD_ID_EXTRA, messageRecord.getThreadId()); intent.putExtra(MessageDetailsActivity.TYPE_EXTRA, messageRecord.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT); - intent.putExtra(MessageDetailsActivity.IS_PUSH_GROUP_EXTRA, groupThread && messageRecord.isPush()); + intent.putExtra(MessageDetailsActivity.IS_PUSH_GROUP_EXTRA, groupThread); intent.putExtra(MessageDetailsActivity.ADDRESS_EXTRA, conversationRecipient.getAddress()); context.startActivity(intent); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java index f288ac5ba5..ece1bb784d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java @@ -14,6 +14,7 @@ import android.view.WindowManager; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.ListenableFuture; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import java.util.concurrent.ExecutionException; @@ -80,9 +81,9 @@ public class ConversationPopupActivity extends ConversationActivity { @Override public void onSuccess(Long result) { ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height); - Intent intent = new Intent(ConversationPopupActivity.this, ConversationActivity.class); - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, getRecipient().getAddress()); - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, result); + Intent intent = new Intent(ConversationPopupActivity.this, ConversationActivityV2.class); + intent.putExtra(ConversationActivityV2.ADDRESS, getRecipient().getAddress()); + intent.putExtra(ConversationActivityV2.THREAD_ID, result); if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { startActivity(intent, transition.toBundle()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 3800f29dac..6dfd2fa887 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; -import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.util.AttributeSet; @@ -15,17 +14,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; +import org.session.libsession.utilities.ExpirationUtil; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsession.utilities.recipients.RecipientModifiedListener; +import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.loki.utilities.GeneralUtilitiesKt; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.util.DateUtils; -import org.session.libsignal.utilities.guava.Optional; - -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientModifiedListener; -import org.session.libsession.utilities.ExpirationUtil; -import org.session.libsession.utilities.Util; import java.util.Locale; import java.util.Set; @@ -101,18 +99,10 @@ public class ConversationUpdateItem extends LinearLayout this.sender.addListener(this); - if (messageRecord.isGroupAction()) setGroupRecord(messageRecord); - else if (messageRecord.isCallLog()) setCallRecord(messageRecord); - else if (messageRecord.isJoined()) setJoinedRecord(messageRecord); + if (messageRecord.isCallLog()) setCallRecord(messageRecord); else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord); - else if (messageRecord.isScreenshotExtraction()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT); - else if (messageRecord.isMediaSavedExtraction()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED); - else if (messageRecord.isEndSession()) setEndSessionRecord(messageRecord); - else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord); - else if (messageRecord.isIdentityVerified() || - messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord); - else if (messageRecord.isLokiSessionRestoreSent()) setTextMessageRecord(messageRecord); - else if (messageRecord.isLokiSessionRestoreDone()) setTextMessageRecord(messageRecord); + else if (messageRecord.isScreenshotNotification()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT); + else if (messageRecord.isMediaSavedNotification()) setDataExtractionRecord(messageRecord, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED); else throw new AssertionError("Neither group nor log nor joined."); if (batchSelected.contains(messageRecord)) setSelected(true); @@ -166,58 +156,6 @@ public class ConversationUpdateItem extends LinearLayout date.setVisibility(GONE); } - private void setIdentityRecord(final MessageRecord messageRecord) { - icon.setImageResource(R.drawable.ic_security_white_24dp); - icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY)); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); - } - - private void setIdentityVerifyUpdate(final MessageRecord messageRecord) { - if (messageRecord.isIdentityVerified()) icon.setImageResource(R.drawable.ic_check_white_24dp); - else icon.setImageResource(R.drawable.ic_info_outline_white_24dp); - - icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY)); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); - } - - private void setGroupRecord(MessageRecord messageRecord) { - icon.setImageResource(R.drawable.ic_group_grey600_24dp); - icon.clearColorFilter(); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); - } - - private void setJoinedRecord(MessageRecord messageRecord) { - icon.setImageResource(R.drawable.ic_favorite_grey600_24dp); - icon.clearColorFilter(); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); - } - - private void setEndSessionRecord(MessageRecord messageRecord) { - icon.setImageResource(R.drawable.ic_refresh_white_24dp); - icon.setColorFilter(new PorterDuffColorFilter(Color.parseColor("#757575"), PorterDuff.Mode.MULTIPLY)); - body.setText(messageRecord.getDisplayBody(getContext())); - - title.setVisibility(GONE); - body.setVisibility(VISIBLE); - date.setVisibility(GONE); - } - private void setTextMessageRecord(MessageRecord messageRecord) { body.setText(messageRecord.getDisplayBody(getContext())); @@ -254,36 +192,7 @@ public class ConversationUpdateItem extends LinearLayout @Override public void onClick(View v) { - if ((!messageRecord.isIdentityUpdate() && - !messageRecord.isIdentityDefault() && - !messageRecord.isIdentityVerified()) || - !batchSelected.isEmpty()) - { - if (parent != null) parent.onClick(v); - return; - } - final Recipient sender = ConversationUpdateItem.this.sender; - -// IdentityUtil.getRemoteIdentityKey(getContext(), sender).addListener(new ListenableFuture.Listener>() { -// @Override -// public void onSuccess(Optional result) { -// if (result.isPresent()) { -// Intent intent = new Intent(getContext(), VerifyIdentityActivity.class); -// intent.putExtra(VerifyIdentityActivity.ADDRESS_EXTRA, sender.getAddress()); -// intent.putExtra(VerifyIdentityActivity.IDENTITY_EXTRA, new IdentityKeyParcelable(result.get().getIdentityKey())); -// intent.putExtra(VerifyIdentityActivity.VERIFIED_EXTRA, result.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED); -// -// getContext().startActivity(intent); -// } -// } -// -// @Override -// public void onFailure(ExecutionException e) { -// Log.w(TAG, e); -// } -// }); } } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt new file mode 100644 index 0000000000..cf36023b17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -0,0 +1,1312 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.Manifest +import android.animation.FloatEvaluator +import android.animation.ValueAnimator +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.database.Cursor +import android.graphics.Rect +import android.graphics.Typeface +import android.net.Uri +import android.os.* +import android.text.TextUtils +import android.util.Log +import android.util.Pair +import android.util.TypedValue +import android.view.* +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.annotation.DimenRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProviders +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.annimon.stream.Stream +import kotlinx.android.synthetic.main.activity_conversation_v2.* +import kotlinx.android.synthetic.main.activity_conversation_v2.view.* +import kotlinx.android.synthetic.main.activity_conversation_v2_action_bar.* +import kotlinx.android.synthetic.main.activity_home.* +import kotlinx.android.synthetic.main.view_conversation.view.* +import kotlinx.android.synthetic.main.view_input_bar.view.* +import kotlinx.android.synthetic.main.view_input_bar_recording.* +import kotlinx.android.synthetic.main.view_input_bar_recording.view.* +import network.loki.messenger.R +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.mentions.Mention +import org.session.libsession.messaging.mentions.MentionsManager +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.LinkPreview.Companion.from +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.Quote.Companion.from +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.messaging.utilities.UpdateMessageData.Companion.fromJSON +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.concurrent.SimpleTask +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientModifiedListener +import org.session.libsignal.utilities.ListenableFuture +import org.session.libsignal.utilities.SettableFuture +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.audio.AudioRecorder +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher +import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.conversation.v2.dialogs.* +import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton +import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate +import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarRecordingViewDelegate +import org.thoughtcrime.securesms.conversation.v2.input_bar.mentions.MentionCandidatesView +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar +import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel +import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.DraftDatabase.Drafts +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.giph.ui.GiphyActivity +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState +import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity +import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity.Companion.selectedContactsKey +import org.thoughtcrime.securesms.loki.utilities.ActivityDispatcher +import org.thoughtcrime.securesms.loki.utilities.MentionUtilities +import org.thoughtcrime.securesms.loki.utilities.push +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendActivity +import org.thoughtcrime.securesms.mms.* +import org.thoughtcrime.securesms.notifications.MarkReadReceiver +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SaveAttachmentTask +import java.util.* +import java.util.concurrent.ExecutionException +import kotlin.math.* + +// Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually +// part of the conversation activity layout. This is just because it makes the layout a lot simpler. The +// price we pay is a bit of back and forth between the input bar and the conversation activity. + +class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, + InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, + ConversationActionModeCallbackDelegate, VisibleMessageContentViewDelegate, RecipientModifiedListener, + SearchBottomBar.EventListener { + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels + private var linkPreviewViewModel: LinkPreviewViewModel? = null + private var threadID: Long = -1 + private var actionMode: ActionMode? = null + private var unreadCount = 0 + // Attachments + private val audioRecorder = AudioRecorder(this) + private val stopAudioHandler = Handler(Looper.getMainLooper()) + private val stopVoiceMessageRecordingTask = Runnable { sendVoiceMessage() } + private val attachmentManager by lazy { AttachmentManager(this, this) } + private var isLockViewExpanded = false + private var isShowingAttachmentOptions = false + // Mentions + private val mentions = mutableListOf() + private var mentionCandidatesView: MentionCandidatesView? = null + private var previousText: CharSequence = "" + private var currentMentionStartIndex = -1 + private var isShowingMentionCandidatesView = false + // Search + var searchViewModel: SearchViewModel? = null + var searchViewItem: MenuItem? = null + + private val isScrolledToBottom: Boolean + get() { + val position = layoutManager.findFirstCompletelyVisibleItemPosition() + return position == 0 + } + + private val layoutManager: LinearLayoutManager + get() { return conversationRecyclerView.layoutManager as LinearLayoutManager } + + private val adapter by lazy { + val cursor = DatabaseFactory.getMmsSmsDatabase(this).getConversation(threadID) + val adapter = ConversationAdapter( + this, + cursor, + onItemPress = { message, position, view, event -> + handlePress(message, position, view, event) + }, + onItemSwipeToReply = { message, position -> + handleSwipeToReply(message, position) + }, + onItemLongPress = { message, position -> + handleLongPress(message, position) + }, + glide + ) + adapter.visibleMessageContentViewDelegate = this + adapter + } + + private val thread by lazy { + DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadID)!! + } + + private val glide by lazy { GlideApp.with(this) } + private val lockViewHitMargin by lazy { toPx(40, resources) } + private val gifButton by lazy { InputBarButton(this, R.drawable.ic_gif_white_24dp, hasOpaqueBackground = true, isGIFButton = true) } + private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } + private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } + private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } + + // region Settings + companion object { + // Extras + const val THREAD_ID = "thread_id" + const val ADDRESS = "address" + // Request codes + const val PICK_DOCUMENT = 2 + const val TAKE_PHOTO = 7 + const val PICK_GIF = 10 + const val PICK_FROM_LIBRARY = 12 + const val INVITE_CONTACTS = 124 + } + // endregion + + // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { + super.onCreate(savedInstanceState, isReady) + setContentView(R.layout.activity_conversation_v2) + var threadID = intent.getLongExtra(THREAD_ID, -1L) + if (threadID == -1L) { + val address = intent.getParcelableExtra
(ADDRESS) ?: return finish() + val recipient = Recipient.from(this, address, false) + threadID = DatabaseFactory.getThreadDatabase(this).getOrCreateThreadIdFor(recipient) + } + this.threadID = threadID + setUpRecyclerView() + setUpToolBar() + setUpInputBar() + restoreDraftIfNeeded() + addOpenGroupGuidelinesIfNeeded() + scrollToBottomButton.setOnClickListener { conversationRecyclerView.smoothScrollToPosition(0) } + unreadCount = DatabaseFactory.getMmsSmsDatabase(this).getUnreadCount(threadID) + updateUnreadCountIndicator() + setUpTypingObserver() + setUpRecipientObserver() + updateSubtitle() + getLatestOpenGroupInfoIfNeeded() + setUpBlockedBanner() + setUpLinkPreviewObserver() + searchBottomBar.setEventListener(this) + setUpSearchResultObserver() + scrollToFirstUnreadMessageIfNeeded() + markAllAsRead() + showOrHideInputIfNeeded() + } + + override fun onResume() { + super.onResume() + ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(threadID) + } + + override fun onPause() { + super.onPause() + ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) + } + + override fun getSystemService(name: String): Any? { + if (name == ActivityDispatcher.SERVICE) { + return this + } + return super.getSystemService(name) + } + + override fun dispatchIntent(body: (Context) -> Intent?) { + val intent = body(this) ?: return + push(intent, false) + } + + private fun setUpRecyclerView() { + conversationRecyclerView.adapter = adapter + val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) + conversationRecyclerView.layoutManager = layoutManager + // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) + LoaderManager.getInstance(this).restartLoader(0, null, object : LoaderManager.LoaderCallbacks { + + override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { + return ConversationLoader(threadID, this@ConversationActivityV2) + } + + override fun onLoadFinished(loader: Loader, cursor: Cursor?) { + adapter.changeCursor(cursor) + } + + override fun onLoaderReset(cursor: Loader) { + adapter.changeCursor(null) + } + }) + conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + handleRecyclerViewScrolled() + } + }) + } + + private fun setUpToolBar() { + val actionBar = supportActionBar!! + actionBar.setCustomView(R.layout.activity_conversation_v2_action_bar) + actionBar.setDisplayShowCustomEnabled(true) + conversationTitleView.text = thread.toShortString() + @DimenRes val sizeID: Int + if (thread.isClosedGroupRecipient) { + sizeID = R.dimen.medium_profile_picture_size + } else { + sizeID = R.dimen.small_profile_picture_size + } + val size = resources.getDimension(sizeID).roundToInt() + profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) + profilePictureView.glide = glide + profilePictureView.update(thread, threadID) + } + + private fun setUpInputBar() { + inputBar.delegate = this + inputBarRecordingView.delegate = this + // GIF button + gifButtonContainer.addView(gifButton) + gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + gifButton.onUp = { showGIFPicker() } + gifButton.snIsEnabled = false + // Document button + documentButtonContainer.addView(documentButton) + documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + documentButton.onUp = { showDocumentPicker() } + documentButton.snIsEnabled = false + // Library button + libraryButtonContainer.addView(libraryButton) + libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + libraryButton.onUp = { pickFromLibrary() } + libraryButton.snIsEnabled = false + // Camera button + cameraButtonContainer.addView(cameraButton) + cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + cameraButton.onUp = { showCamera() } + cameraButton.snIsEnabled = false + } + + private fun restoreDraftIfNeeded() { + val mediaURI = intent.data + val mediaType = AttachmentManager.MediaType.from(intent.type) + if (mediaURI != null && mediaType != null) { + if (AttachmentManager.MediaType.IMAGE == mediaType || AttachmentManager.MediaType.GIF == mediaType || AttachmentManager.MediaType.VIDEO == mediaType) { + val media = Media(mediaURI, MediaUtil.getMimeType(this, mediaURI)!!, 0, 0, 0, 0, Optional.absent(), Optional.absent()) + startActivityForResult(MediaSendActivity.buildEditorIntent(this, listOf( media ), thread, ""), ConversationActivityV2.PICK_FROM_LIBRARY) + return + } else { + prepMediaForSending(mediaURI, mediaType).addListener(object : ListenableFuture.Listener { + + override fun onSuccess(result: Boolean?) { + sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) + } + + override fun onFailure(e: ExecutionException?) { + Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() + } + }) + return + } + } + val draftDB = DatabaseFactory.getDraftDatabase(this) + val drafts = draftDB.getDrafts(threadID) + draftDB.clearDrafts(threadID) + val text = drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value ?: return + inputBar.text = text + } + + private fun addOpenGroupGuidelinesIfNeeded() { + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) ?: return + val isOxenHostedOpenGroup = openGroup.room == "session" || openGroup.room == "oxen" + || openGroup.room == "lokinet" || openGroup.room == "crypto" + if (!isOxenHostedOpenGroup) { return } + openGroupGuidelinesView.visibility = View.VISIBLE + val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams + recyclerViewLayoutParams.topMargin = toPx(57, resources) // The height of the open group guidelines view is hardcoded to this + conversationRecyclerView.layoutParams = recyclerViewLayoutParams + } + + private fun setUpTypingObserver() { + ApplicationContext.getInstance(this).typingStatusRepository.getTypists(threadID).observe(this) { state -> + val recipients = if (state != null) state.typists else listOf() + // FIXME: Also checking isScrolledToBottom is a quick fix for an issue where the + // typing indicator overlays the recycler view when scrolled up + typingIndicatorViewContainer.isVisible = recipients.isNotEmpty() && isScrolledToBottom + typingIndicatorViewContainer.setTypists(recipients) + inputBarHeightChanged(inputBar.height) + } + if (TextSecurePreferences.isTypingIndicatorsEnabled(this)) { + inputBar.inputBarEditText.addTextChangedListener(object : SimpleTextWatcher() { + + override fun onTextChanged(text: String?) { + ApplicationContext.getInstance(this@ConversationActivityV2).typingStatusSender.onTypingStarted(threadID) + } + }) + } + } + + private fun setUpRecipientObserver() { + thread.addListener(this) + } + + private fun getLatestOpenGroupInfoIfNeeded() { + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) ?: return + OpenGroupAPIV2.getMemberCount(openGroup.room, openGroup.server).successUi { updateSubtitle() } + } + + private fun setUpBlockedBanner() { + if (thread.isGroupRecipient) { return } + val contactDB = DatabaseFactory.getSessionContactDatabase(this) + val sessionID = thread.address.toString() + val contact = contactDB.getContactWithSessionID(sessionID) + val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + blockedBannerTextView.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) + blockedBanner.isVisible = thread.isBlocked + blockedBanner.setOnClickListener { unblock() } + } + + private fun setUpLinkPreviewObserver() { + val linkPreviewViewModel = ViewModelProviders.of(this, LinkPreviewViewModel.Factory(LinkPreviewRepository(this)))[LinkPreviewViewModel::class.java] + this.linkPreviewViewModel = linkPreviewViewModel + if (!TextSecurePreferences.isLinkPreviewsEnabled(this)) { + linkPreviewViewModel.onUserCancel(); return + } + linkPreviewViewModel.linkPreviewState.observe(this, { previewState: LinkPreviewState? -> + if (previewState == null) return@observe + if (previewState.isLoading) { + inputBar.draftLinkPreview() + } else if (previewState.linkPreview.isPresent) { + inputBar.updateLinkPreviewDraft(glide, previewState.linkPreview.get()) + } else { + inputBar.cancelLinkPreviewDraft() + } + }) + } + + private fun scrollToFirstUnreadMessageIfNeeded() { + val lastSeenTimestamp = DatabaseFactory.getThreadDatabase(this).getLastSeenAndHasSent(threadID).first() + val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return + if (lastSeenItemPosition <= 3) { return } + conversationRecyclerView.scrollToPosition(lastSeenItemPosition) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + ConversationMenuHelper.onPrepareOptionsMenu(menu, menuInflater, thread, threadID, this) { onOptionsItemSelected(it) } + super.onPrepareOptionsMenu(menu) + return true + } + + override fun onDestroy() { + saveDraft() + super.onDestroy() + } + // endregion + + // region Animation & Updating + override fun onModified(recipient: Recipient) { + runOnUiThread { + if (thread.isContactRecipient) { + blockedBanner.isVisible = thread.isBlocked + } + updateSubtitle() + showOrHideInputIfNeeded() + } + } + + private fun showOrHideInputIfNeeded() { + if (thread.isClosedGroupRecipient) { + val group = DatabaseFactory.getGroupDatabase(this).getGroup(thread.address.toGroupString()).orNull() + val isActive = (group?.isActive == true) + inputBar.showInput = isActive + } else { + inputBar.showInput = true + } + } + + private fun markAllAsRead() { + val messages = DatabaseFactory.getThreadDatabase(this).setRead(threadID, true) + if (thread.isGroupRecipient) { + for (message in messages) { + MarkReadReceiver.scheduleDeletion(this, message.expirationInfo) + } + } else { + MarkReadReceiver.process(this, messages) + } + ApplicationContext.getInstance(this).messageNotifier.updateNotification(this) + } + + override fun inputBarHeightChanged(newValue: Int) { + @Suppress("NAME_SHADOWING") val newValue = max(newValue, resources.getDimension(R.dimen.input_bar_height).roundToInt()) + // 36 DP is the exact height of the typing indicator view. It's also exactly 18 * 2, and 18 is the large message + // corner radius. This makes 36 DP look "correct" in the context of other messages on the screen. + val typingIndicatorHeight = if (typingIndicatorViewContainer.isVisible) toPx(36, resources) else 0 + // Recycler view + val recyclerViewLayoutParams = conversationRecyclerView.layoutParams as RelativeLayout.LayoutParams + recyclerViewLayoutParams.bottomMargin = newValue + typingIndicatorHeight + conversationRecyclerView.layoutParams = recyclerViewLayoutParams + // Additional content container + val additionalContentContainerLayoutParams = additionalContentContainer.layoutParams as RelativeLayout.LayoutParams + additionalContentContainerLayoutParams.bottomMargin = newValue + additionalContentContainer.layoutParams = additionalContentContainerLayoutParams + // Attachment options + val attachmentButtonHeight = inputBar.attachmentsButtonContainer.height + val bottomMargin = (newValue - inputBar.additionalContentHeight - attachmentButtonHeight) / 2 + val margin = toPx(8, resources) + val attachmentOptionsContainerLayoutParams = attachmentOptionsContainer.layoutParams as RelativeLayout.LayoutParams + attachmentOptionsContainerLayoutParams.bottomMargin = bottomMargin + attachmentButtonHeight + margin + attachmentOptionsContainer.layoutParams = attachmentOptionsContainerLayoutParams + // Scroll to bottom button + val scrollToBottomButtonLayoutParams = scrollToBottomButton.layoutParams as RelativeLayout.LayoutParams + scrollToBottomButtonLayoutParams.bottomMargin = newValue + additionalContentContainer.height + toPx(12, resources) + scrollToBottomButton.layoutParams = scrollToBottomButtonLayoutParams + } + + override fun inputBarEditTextContentChanged(newContent: CharSequence) { + if (TextSecurePreferences.isLinkPreviewsEnabled(this)) { + linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0) + } + showOrHideMentionCandidatesIfNeeded(newContent) + if (LinkPreviewUtil.findWhitelistedUrls(newContent.toString()).isNotEmpty() + && !TextSecurePreferences.isLinkPreviewsEnabled(this) && !TextSecurePreferences.hasSeenLinkPreviewSuggestionDialog(this)) { + LinkPreviewDialog { + setUpLinkPreviewObserver() + linkPreviewViewModel?.onEnabled() + linkPreviewViewModel?.onTextChanged(this, inputBar.text, 0, 0) + }.show(supportFragmentManager, "Link Preview Dialog") + TextSecurePreferences.setHasSeenLinkPreviewSuggestionDialog(this) + } + } + + private fun showOrHideMentionCandidatesIfNeeded(text: CharSequence) { + if (text.length < previousText.length) { + currentMentionStartIndex = -1 + hideMentionCandidates() + val mentionsToRemove = mentions.filter { !text.contains(it.displayName) } + mentions.removeAll(mentionsToRemove) + } + if (text.isNotEmpty()) { + val lastCharIndex = text.lastIndex + val lastChar = text[lastCharIndex] + // Check if there is whitespace before the '@' or the '@' is the first character + val isCharacterBeforeLastWhiteSpaceOrStartOfLine: Boolean + if (text.length == 1) { + isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line + } else { + val charBeforeLast = text[lastCharIndex - 1] + isCharacterBeforeLastWhiteSpaceOrStartOfLine = Character.isWhitespace(charBeforeLast) + } + if (lastChar == '@' && isCharacterBeforeLastWhiteSpaceOrStartOfLine) { + currentMentionStartIndex = lastCharIndex + showOrUpdateMentionCandidatesIfNeeded() + } else if (Character.isWhitespace(lastChar) || lastChar == '@') { // the lastCharacter == "@" is to check for @@ + currentMentionStartIndex = -1 + hideMentionCandidates() + } else if (currentMentionStartIndex != -1) { + val query = text.substring(currentMentionStartIndex + 1) // + 1 to get rid of the "@" + showOrUpdateMentionCandidatesIfNeeded(query) + } + } + previousText = text + } + + private fun showOrUpdateMentionCandidatesIfNeeded(query: String = "") { + if (!isShowingMentionCandidatesView) { + additionalContentContainer.removeAllViews() + val view = MentionCandidatesView(this) + view.glide = glide + view.onCandidateSelected = { handleMentionSelected(it) } + additionalContentContainer.addView(view) + val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + this.mentionCandidatesView = view + view.show(candidates, threadID) + view.alpha = 0.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, 1.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + view.alpha = animator.animatedValue as Float + } + animation.start() + } else { + val candidates = MentionsManager.getMentionCandidates(query, threadID, thread.isOpenGroupRecipient) + this.mentionCandidatesView!!.setMentionCandidates(candidates) + } + isShowingMentionCandidatesView = true + } + + private fun hideMentionCandidates() { + if (isShowingMentionCandidatesView) { + val mentionCandidatesView = mentionCandidatesView ?: return + val animation = ValueAnimator.ofObject(FloatEvaluator(), mentionCandidatesView.alpha, 0.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + mentionCandidatesView.alpha = animator.animatedValue as Float + if (animator.animatedFraction == 1.0f) { additionalContentContainer.removeAllViews() } + } + animation.start() + } + isShowingMentionCandidatesView = false + } + + override fun toggleAttachmentOptions() { + val targetAlpha = if (isShowingAttachmentOptions) 0.0f else 1.0f + val allButtonContainers = listOf( cameraButtonContainer, libraryButtonContainer, documentButtonContainer, gifButtonContainer) + val isReversed = isShowingAttachmentOptions // Run the animation in reverse + val count = allButtonContainers.size + allButtonContainers.indices.forEach { index -> + val view = allButtonContainers[index] + val animation = ValueAnimator.ofObject(FloatEvaluator(), view.alpha, targetAlpha) + animation.duration = 250L + animation.startDelay = if (isReversed) 50L * (count - index.toLong()) else 50L * index.toLong() + animation.addUpdateListener { animator -> + view.alpha = animator.animatedValue as Float + } + animation.start() + } + isShowingAttachmentOptions = !isShowingAttachmentOptions + val allButtons = listOf( cameraButton, libraryButton, documentButton, gifButton ) + allButtons.forEach { it.snIsEnabled = isShowingAttachmentOptions } + } + + override fun showVoiceMessageUI() { + inputBarRecordingView.show() + inputBar.alpha = 0.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + inputBar.alpha = animator.animatedValue as Float + } + animation.start() + } + + private fun expandVoiceMessageLockView() { + val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.10f) + animation.duration = 250L + animation.addUpdateListener { animator -> + lockView.scaleX = animator.animatedValue as Float + lockView.scaleY = animator.animatedValue as Float + } + animation.start() + } + + private fun collapseVoiceMessageLockView() { + val animation = ValueAnimator.ofObject(FloatEvaluator(), lockView.scaleX, 1.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + lockView.scaleX = animator.animatedValue as Float + lockView.scaleY = animator.animatedValue as Float + } + animation.start() + } + + private fun hideVoiceMessageUI() { + val chevronImageView = inputBarRecordingView.inputBarChevronImageView + val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView + listOf( chevronImageView, slideToCancelTextView ).forEach { view -> + val animation = ValueAnimator.ofObject(FloatEvaluator(), view.translationX, 0.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + view.translationX = animator.animatedValue as Float + } + animation.start() + } + inputBarRecordingView.hide() + } + + override fun handleVoiceMessageUIHidden() { + inputBar.alpha = 1.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + inputBar.alpha = animator.animatedValue as Float + } + animation.start() + } + + private fun handleRecyclerViewScrolled() { + val alpha = if (!isScrolledToBottom) 1.0f else 0.0f + // FIXME: Checking isScrolledToBottom is a quick fix for an issue where the + // typing indicator overlays the recycler view when scrolled up + val wasTypingIndicatorVisibleBefore = typingIndicatorViewContainer.isVisible + typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom + val isTypingIndicatorVisibleAfter = typingIndicatorViewContainer.isVisible + if (isTypingIndicatorVisibleAfter != wasTypingIndicatorVisibleBefore) { + inputBarHeightChanged(inputBar.height) + } + scrollToBottomButton.alpha = alpha + unreadCount = min(unreadCount, layoutManager.findFirstVisibleItemPosition()) + updateUnreadCountIndicator() + } + + private fun updateUnreadCountIndicator() { + val formattedUnreadCount = if (unreadCount < 100) unreadCount.toString() else "99+" + unreadCountTextView.text = formattedUnreadCount + val textSize = if (unreadCount < 100) 12.0f else 9.0f + unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) + unreadCountIndicator.isVisible = (unreadCount != 0) + } + + private fun updateSubtitle() { + muteIconImageView.isVisible = thread.isMuted + conversationSubtitleView.isVisible = true + if (thread.isMuted) { + conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(thread.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) + } else if (thread.isGroupRecipient) { + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) + if (openGroup != null) { + val userCount = DatabaseFactory.getLokiAPIDatabase(this).getUserCount(openGroup.room, openGroup.server) ?: 0 + conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) + } else { + conversationSubtitleView.isVisible = false + } + } else { + conversationSubtitleView.isVisible = false + } + } + // endregion + + // region Interaction + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + return false + } + return ConversationMenuHelper.onOptionItemSelected(this, item, thread) + } + + // `position` is the adapter position; not the visual position + private fun handlePress(message: MessageRecord, position: Int, view: VisibleMessageView, event: MotionEvent) { + val actionMode = this.actionMode + if (actionMode != null) { + adapter.toggleSelection(message, position) + val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this) + actionModeCallback.delegate = this + actionModeCallback.updateActionModeMenu(actionMode.menu) + if (adapter.selectedItems.isEmpty()) { + actionMode.finish() + this.actionMode = null + } + } else { + // NOTE: + // We have to use onContentClick (rather than a click listener directly on + // the view) so as to not interfere with all the other gestures. Do not add + // onClickListeners directly to message content views. + view.onContentClick(event) + } + } + + // `position` is the adapter position; not the visual position + private fun handleSwipeToReply(message: MessageRecord, position: Int) { + inputBar.draftQuote(thread, message, glide) + } + + // `position` is the adapter position; not the visual position + private fun handleLongPress(message: MessageRecord, position: Int) { + val actionMode = this.actionMode + val actionModeCallback = ConversationActionModeCallback(adapter, threadID, this) + actionModeCallback.delegate = this + searchViewItem?.collapseActionView() + if (actionMode == null) { // Nothing should be selected if this is the case + adapter.toggleSelection(message, position) + this.actionMode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) + } else { + startActionMode(actionModeCallback) + } + } else { + adapter.toggleSelection(message, position) + actionModeCallback.updateActionModeMenu(actionMode.menu) + if (adapter.selectedItems.isEmpty()) { + actionMode.finish() + this.actionMode = null + } + } + } + + override fun onMicrophoneButtonMove(event: MotionEvent) { + val rawX = event.rawX + val chevronImageView = inputBarRecordingView.inputBarChevronImageView + val slideToCancelTextView = inputBarRecordingView.inputBarSlideToCancelTextView + if (rawX < screenWidth / 2) { + val translationX = rawX - screenWidth / 2 + val sign = -1.0f + val chevronDamping = 4.0f + val labelDamping = 3.0f + val chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign + val labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign + chevronImageView.translationX = chevronX + slideToCancelTextView.translationX = labelX + } else { + chevronImageView.translationX = 0.0f + slideToCancelTextView.translationX = 0.0f + } + if (isValidLockViewLocation(event.rawX.roundToInt(), event.rawY.roundToInt())) { + if (!isLockViewExpanded) { + expandVoiceMessageLockView() + isLockViewExpanded = true + } + } else { + if (isLockViewExpanded) { + collapseVoiceMessageLockView() + isLockViewExpanded = false + } + } + } + + override fun onMicrophoneButtonCancel(event: MotionEvent) { + hideVoiceMessageUI() + } + + override fun onMicrophoneButtonUp(event: MotionEvent) { + val x = event.rawX.roundToInt() + val y = event.rawY.roundToInt() + if (isValidLockViewLocation(x, y)) { + inputBarRecordingView.lock() + } else { + val recordButtonOverlay = inputBarRecordingView.recordButtonOverlay + val location = IntArray(2) { 0 } + recordButtonOverlay.getLocationOnScreen(location) + val hitRect = Rect(location[0], location[1], location[0] + recordButtonOverlay.width, location[1] + recordButtonOverlay.height) + if (hitRect.contains(x, y)) { + sendVoiceMessage() + } else { + cancelVoiceMessage() + } + } + } + + private fun isValidLockViewLocation(x: Int, y: Int): Boolean { + // We can be anywhere above the lock view and a bit to the side of it (at most `lockViewHitMargin` + // to the side) + val lockViewLocation = IntArray(2) { 0 } + lockView.getLocationOnScreen(lockViewLocation) + val hitRect = Rect(lockViewLocation[0] - lockViewHitMargin, 0, + lockViewLocation[0] + lockView.width + lockViewHitMargin, lockViewLocation[1] + lockView.height) + return hitRect.contains(x, y) + } + + private fun unblock() { + if (!thread.isContactRecipient) { return } + DatabaseFactory.getRecipientDatabase(this).setBlocked(thread, false) + } + + private fun handleMentionSelected(mention: Mention) { + if (currentMentionStartIndex == -1) { return } + mentions.add(mention) + val previousText = inputBar.text + val newText = previousText.substring(0, currentMentionStartIndex) + "@" + mention.displayName + " " + inputBar.text = newText + inputBar.inputBarEditText.setSelection(newText.length) + currentMentionStartIndex = -1 + hideMentionCandidates() + this.previousText = newText + } + + override fun scrollToMessageIfPossible(timestamp: Long) { + val lastSeenItemPosition = adapter.getItemPositionForTimestamp(timestamp) ?: return + conversationRecyclerView.scrollToPosition(lastSeenItemPosition) + } + + override fun sendMessage() { + if (thread.isContactRecipient && thread.isBlocked) { + BlockedDialog(thread).show(supportFragmentManager, "Blocked Dialog") + return + } + if (inputBar.linkPreview != null || inputBar.quote != null) { + sendAttachments(listOf(), getMessageBody(), inputBar.quote, inputBar.linkPreview) + } else { + sendTextOnlyMessage() + } + } + + private fun sendTextOnlyMessage() { + // Create the message + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + message.text = getMessageBody() + val outgoingTextMessage = OutgoingTextMessage.from(message, thread) + // Clear the input bar + inputBar.text = "" + inputBar.cancelQuoteDraft() + inputBar.cancelLinkPreviewDraft() + // Clear mentions + previousText = "" + currentMentionStartIndex = -1 + mentions.clear() + // Put the message in the database + message.id = DatabaseFactory.getSmsDatabase(this).insertMessageOutbox(threadID, outgoingTextMessage, false, message.sentTimestamp!!) { } + // Send it + MessageSender.send(message, thread.address) + // Send a typing stopped message + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + } + + private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { + // Create the message + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + message.text = body + val quote = quotedMessage?.let { + val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() + QuoteModel(it.dateSent, it.individualRecipient.address, it.body, false, quotedAttachments) + } + val outgoingTextMessage = OutgoingMediaMessage.from(message, thread, attachments, quote, linkPreview) + // Clear the input bar + inputBar.text = "" + inputBar.cancelQuoteDraft() + inputBar.cancelLinkPreviewDraft() + // Clear mentions + previousText = "" + currentMentionStartIndex = -1 + mentions.clear() + // Reset the attachment manager + attachmentManager.clear() + // Reset attachments button if needed + if (isShowingAttachmentOptions) { toggleAttachmentOptions() } + // Put the message in the database + message.id = DatabaseFactory.getMmsDatabase(this).insertMessageOutbox(outgoingTextMessage, threadID, false) { } + // Send it + MessageSender.send(message, thread.address, attachments, quote, linkPreview) + // Send a typing stopped message + ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(threadID) + } + + private fun showGIFPicker() { + AttachmentManager.selectGif(this, ConversationActivityV2.PICK_GIF) + } + + private fun showDocumentPicker() { + AttachmentManager.selectDocument(this, ConversationActivityV2.PICK_DOCUMENT) + } + + private fun pickFromLibrary() { + AttachmentManager.selectGallery(this, ConversationActivityV2.PICK_FROM_LIBRARY, thread, inputBar.text.trim()) + } + + private fun showCamera() { + attachmentManager.capturePhoto(this, ConversationActivityV2.TAKE_PHOTO) + } + + override fun onAttachmentChanged() { + // Do nothing + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + val mediaPreppedListener = object : ListenableFuture.Listener { + + override fun onSuccess(result: Boolean?) { + sendAttachments(attachmentManager.buildSlideDeck().asAttachments(), null) + } + + override fun onFailure(e: ExecutionException?) { + Toast.makeText(this@ConversationActivityV2, R.string.activity_conversation_attachment_prep_failed, Toast.LENGTH_LONG).show() + } + } + when (requestCode) { + PICK_DOCUMENT -> { + val uri = intent?.data ?: return + prepMediaForSending(uri, AttachmentManager.MediaType.DOCUMENT).addListener(mediaPreppedListener) + } + TAKE_PHOTO -> { + if (resultCode != RESULT_OK) { return } + val uri = attachmentManager.captureUri ?: return + prepMediaForSending(uri, AttachmentManager.MediaType.IMAGE).addListener(mediaPreppedListener) + } + PICK_GIF -> { + intent ?: return + val uri = intent.data ?: return + val type = AttachmentManager.MediaType.GIF + val width = intent.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0) + val height = intent.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0) + prepMediaForSending(uri, type, width, height).addListener(mediaPreppedListener) + } + PICK_FROM_LIBRARY -> { + intent ?: return + val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE) + val media = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return + val slideDeck = SlideDeck() + for (item in media) { + when { + MediaUtil.isVideoType(item.mimeType) -> { + slideDeck.addSlide(VideoSlide(this, item.uri, 0, item.caption.orNull())) + } + MediaUtil.isGif(item.mimeType) -> { + slideDeck.addSlide(GifSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) + } + MediaUtil.isImageType(item.mimeType) -> { + slideDeck.addSlide(ImageSlide(this, item.uri, 0, item.width, item.height, item.caption.orNull())) + } + else -> { + Log.d("Loki", "Asked to send an unexpected media type: '" + item.mimeType + "'. Skipping.") + } + } + } + sendAttachments(slideDeck.asAttachments(), body) + } + INVITE_CONTACTS -> { + if (!thread.isOpenGroupRecipient) { return } + val extras = intent?.extras ?: return + if (!intent.hasExtra(SelectContactsActivity.selectedContactsKey)) { return } + val selectedContacts = extras.getStringArray(selectedContactsKey)!! + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) + for (contact in selectedContacts) { + val recipient = Recipient.from(this, fromSerialized(contact), true) + val message = VisibleMessage() + message.sentTimestamp = System.currentTimeMillis() + val openGroupInvitation = OpenGroupInvitation() + openGroupInvitation.name = openGroup!!.name + openGroupInvitation.url = openGroup!!.joinURL + message.openGroupInvitation = openGroupInvitation + val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation(openGroupInvitation, recipient, message.sentTimestamp) + DatabaseFactory.getSmsDatabase(this).insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!) + MessageSender.send(message, recipient.address) + } + } + } + } + + private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType): ListenableFuture { + return prepMediaForSending(uri, type, null, null) + } + + private fun prepMediaForSending(uri: Uri, type: AttachmentManager.MediaType, width: Int?, height: Int?): ListenableFuture { + return attachmentManager.setMedia(glide, uri, type, MediaConstraints.getPushMediaConstraints(), width ?: 0, height ?: 0) + } + + override fun startRecordingVoiceMessage() { + if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) { + showVoiceMessageUI() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + audioRecorder.startRecording() + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each + } else { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .withRationaleDialog(getString(R.string.ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone), R.drawable.ic_baseline_mic_48) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages)) + .execute() + } + } + + override fun sendVoiceMessage() { + hideVoiceMessageUI() + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + val future = audioRecorder.stopRecording() + stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + future.addListener(object : ListenableFuture.Listener> { + + override fun onSuccess(result: Pair) { + val audioSlide = AudioSlide(this@ConversationActivityV2, result.first, result.second!!, MediaTypes.AUDIO_AAC, true) + val slideDeck = SlideDeck() + slideDeck.addSlide(audioSlide) + sendAttachments(slideDeck.asAttachments(), null) + } + + override fun onFailure(e: ExecutionException) { + Toast.makeText(this@ConversationActivityV2, R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() + } + }) + } + + override fun cancelVoiceMessage() { + hideVoiceMessageUI() + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + audioRecorder.stopRecording() + stopAudioHandler.removeCallbacks(stopVoiceMessageRecordingTask) + } + + override fun deleteMessages(messages: Set) { + val messageCount = messages.size + val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider + val messageDB = DatabaseFactory.getLokiMessageDatabase(this@ConversationActivityV2) + val builder = AlertDialog.Builder(this) + builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) + builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) + builder.setCancelable(true) + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID) + builder.setPositiveButton(R.string.delete) { _, _ -> + if (openGroup != null) { + val messageServerIDs = mutableMapOf() + for (message in messages) { + val messageServerID = messageDB.getServerID(message.id, !message.isMms) ?: continue + messageServerIDs[messageServerID] = message + } + for ((messageServerID, message) in messageServerIDs) { + OpenGroupAPIV2.deleteMessage(messageServerID, openGroup.room, openGroup.server) + .success { + messageDataProvider.deleteMessage(message.id, !message.isMms) + }.failUi { error -> + Toast.makeText(this@ConversationActivityV2, "Couldn't delete message due to error: $error", Toast.LENGTH_LONG).show() + } + } + } else { + for (message in messages) { + if (message.isMms) { + DatabaseFactory.getMmsDatabase(this@ConversationActivityV2).delete(message.id) + } else { + DatabaseFactory.getSmsDatabase(this@ConversationActivityV2).deleteMessage(message.id) + } + } + } + endActionMode() + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + endActionMode() + } + builder.show() + } + + override fun banUser(messages: Set) { + val builder = AlertDialog.Builder(this) + val sessionID = messages.first().individualRecipient.address.toString() + builder.setTitle(R.string.ConversationFragment_ban_selected_user) + builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms. The selected user won't know that they've been banned.") + builder.setCancelable(true) + val openGroup = DatabaseFactory.getLokiThreadDatabase(this).getOpenGroupChat(threadID)!! + builder.setPositiveButton(R.string.ban) { _, _ -> + OpenGroupAPIV2.ban(sessionID, openGroup.room, openGroup.server).successUi { + Toast.makeText(this@ConversationActivityV2, "Successfully banned user", Toast.LENGTH_LONG).show() + }.failUi { error -> + Toast.makeText(this@ConversationActivityV2, "Couldn't ban user due to error: $error", Toast.LENGTH_LONG).show() + } + endActionMode() + } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + endActionMode() + } + builder.show() + } + + override fun copyMessages(messages: Set) { + val sortedMessages = messages.sortedBy { it.dateSent } + val builder = StringBuilder() + for (message in sortedMessages) { + val body = MentionUtilities.highlightMentions(message.body, message.threadId, this) + if (TextUtils.isEmpty(body)) { continue } + val formattedTimestamp = DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), message.timestamp) + builder.append("$formattedTimestamp: $body").append('\n') + } + if (builder.isNotEmpty() && builder[builder.length - 1] == '\n') { + builder.deleteCharAt(builder.length - 1) + } + val result = builder.toString() + if (TextUtils.isEmpty(result)) { return } + val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(ClipData.newPlainText("Message Content", result)) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + endActionMode() + } + + override fun copySessionID(messages: Set) { + val sessionID = messages.first().individualRecipient.address.toString() + val clip = ClipData.newPlainText("Session ID", sessionID) + val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + endActionMode() + } + + override fun resendMessage(messages: Set) { + messages.forEach { messageRecord -> + val recipient: Recipient = messageRecord.recipient + val message = VisibleMessage() + message.id = messageRecord.getId() + if (messageRecord.isOpenGroupInvitation) { + val openGroupInvitation = OpenGroupInvitation() + fromJSON(messageRecord.body)?.let { updateMessageData -> + val kind = updateMessageData.kind + if (kind is UpdateMessageData.Kind.OpenGroupInvitation) { + openGroupInvitation.name = kind.groupName + openGroupInvitation.url = kind.groupUrl + } + } + message.openGroupInvitation = openGroupInvitation + } else { + message.text = messageRecord.body + } + message.sentTimestamp = messageRecord.timestamp + if (recipient.isGroupRecipient) { + message.groupPublicKey = recipient.address.toGroupString() + } else { + message.recipient = messageRecord.recipient.address.serialize() + } + message.threadID = messageRecord.threadId + if (messageRecord.isMms) { + val mmsMessageRecord = messageRecord as MmsMessageRecord + if (mmsMessageRecord.linkPreviews.isNotEmpty()) { + message.linkPreview = from(mmsMessageRecord.linkPreviews[0]) + } + if (mmsMessageRecord.quote != null) { + message.quote = from(mmsMessageRecord.quote!!.quoteModel) + } + message.addSignalAttachments(mmsMessageRecord.slideDeck.asAttachments()) + } + val sentTimestamp = message.sentTimestamp + val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() + if (sentTimestamp != null && sender != null) { + MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) + } + MessageSender.send(message, recipient.address) + } + endActionMode() + } + + override fun saveAttachment(messages: Set) { + val message = messages.first() as MmsMessageRecord + SaveAttachmentTask.showWarningDialog(this, { _, _ -> + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .maxSdkVersion(Build.VERSION_CODES.P) + .withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied { + endActionMode() + Toast.makeText(this@ConversationActivityV2, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() + } + .onAllGranted { + endActionMode() + val attachments: List = Stream.of(message.slideDeck.slides) + .filter { s: Slide -> s.uri != null && (s.hasImage() || s.hasVideo() || s.hasAudio() || s.hasDocument()) } + .map { s: Slide -> SaveAttachmentTask.Attachment(s.uri!!, s.contentType, message.dateReceived, s.fileName.orNull()) } + .toList() + if (attachments.isNotEmpty()) { + val saveTask = SaveAttachmentTask(this) + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, *attachments.toTypedArray()) + if (!message.isOutgoing) { + sendMediaSavedNotification() + } + return@onAllGranted + } + Toast.makeText(this, + resources.getQuantityString(R.plurals.ConversationFragment_error_while_saving_attachments_to_sd_card, 1), + Toast.LENGTH_LONG).show() + } + .execute() + }) + } + + override fun reply(messages: Set) { + inputBar.draftQuote(thread, messages.first(), glide) + endActionMode() + } + + private fun sendMediaSavedNotification() { + if (thread.isGroupRecipient) { return } + val timestamp = System.currentTimeMillis() + val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) + val message = DataExtractionNotification(kind) + MessageSender.send(message, thread.address) + } + + private fun endActionMode() { + actionMode?.finish() + actionMode = null + } + // endregion + + // region General + private fun getMessageBody(): String { + var result = inputBar.inputBarEditText.text?.trim() ?: "" + for (mention in mentions) { + try { + val startIndex = result.indexOf("@" + mention.displayName) + val endIndex = startIndex + mention.displayName.count() + 1 // + 1 to include the "@" + result = result.substring(0, startIndex) + "@" + mention.publicKey + result.substring(endIndex) + } catch (exception: Exception) { + Log.d("Loki", "Failed to process mention due to error: $exception") + } + } + return result.toString() + } + + private fun saveDraft() { + val text = inputBar.text.trim() + if (text.isEmpty()) { return } + val drafts = Drafts() + drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) + val draftDB = DatabaseFactory.getDraftDatabase(this) + draftDB.insertDrafts(threadID, drafts) + } + // endregion + + // region Search + private fun setUpSearchResultObserver() { + val searchViewModel = ViewModelProvider(this).get(SearchViewModel::class.java) + this.searchViewModel = searchViewModel + searchViewModel.searchResults.observe(this, Observer { result: SearchViewModel.SearchResult? -> + if (result == null) return@Observer + if (result.getResults().isNotEmpty()) { + result.getResults()[result.position]?.let { + jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs, Runnable { searchViewModel.onMissingResult() }) + } + } + this.searchBottomBar.setData(result.position, result.getResults().size) + }) + } + + fun onSearchQueryUpdated(query: String?) { + adapter.onSearchQueryUpdated(query) + } + + override fun onSearchMoveUpPressed() { + this.searchViewModel?.onMoveUp() + } + + override fun onSearchMoveDownPressed() { + this.searchViewModel?.onMoveDown() + } + + private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { + SimpleTask.run(lifecycle, { + DatabaseFactory.getMmsSmsDatabase(this).getMessagePositionInConversation(threadID, timestamp, author) + }) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } + } + + private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { + if (position >= 0) { + conversationRecyclerView.scrollToPosition(position) + } else { + onMessageNotFound?.run() + } + } + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt new file mode 100644 index 0000000000..4b413ef89f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.database.Cursor +import android.graphics.Rect +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import kotlinx.android.synthetic.main.view_visible_message.view.* +import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentViewDelegate +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.mms.GlideRequests + +class ConversationAdapter(context: Context, cursor: Cursor, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, + private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int) -> Unit, + private val glide: GlideRequests) + : CursorRecyclerViewAdapter(context, cursor) { + private val messageDB = DatabaseFactory.getMmsSmsDatabase(context) + var selectedItems = mutableSetOf() + private var searchQuery: String? = null + var visibleMessageContentViewDelegate: VisibleMessageContentViewDelegate? = null + + sealed class ViewType(val rawValue: Int) { + object Visible : ViewType(0) + object Control : ViewType(1) + + companion object { + + val allValues: Map get() = mapOf( + Visible.rawValue to Visible, + Control.rawValue to Control + ) + } + } + + class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view) + class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view) + + override fun getItemViewType(cursor: Cursor): Int { + val message = getMessage(cursor)!! + if (message.isControlMessage) { return ViewType.Control.rawValue } + return ViewType.Visible.rawValue + } + + override fun onCreateItemViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + @Suppress("NAME_SHADOWING") + val viewType = ViewType.allValues[viewType] + when (viewType) { + ViewType.Visible -> { + val view = VisibleMessageView(context) + return VisibleMessageViewHolder(view) + } + ViewType.Control -> { + val view = ControlMessageView(context) + return ControlMessageViewHolder(view) + } + else -> throw IllegalStateException("Unexpected view type: $viewType.") + } + } + + override fun onBindItemViewHolder(viewHolder: ViewHolder, cursor: Cursor) { + val message = getMessage(cursor)!! + when (viewHolder) { + is VisibleMessageViewHolder -> { + val view = viewHolder.view + val isSelected = selectedItems.contains(message) + view.snIsSelected = isSelected + view.messageTimestampTextView.isVisible = isSelected + val position = viewHolder.adapterPosition + view.bind(message, getMessageBefore(position, cursor), getMessageAfter(position, cursor), glide, searchQuery) + view.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, view, event) } + view.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } + view.onLongPress = { onItemLongPress(message, viewHolder.adapterPosition) } + view.contentViewDelegate = visibleMessageContentViewDelegate + } + is ControlMessageViewHolder -> viewHolder.view.bind(message) + } + } + + override fun onItemViewRecycled(viewHolder: ViewHolder?) { + when (viewHolder) { + is VisibleMessageViewHolder -> viewHolder.view.recycle() + is ControlMessageViewHolder -> viewHolder.view.recycle() + } + super.onItemViewRecycled(viewHolder) + } + + private fun getMessage(cursor: Cursor): MessageRecord? { + return messageDB.readerFor(cursor).current + } + + private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { + // The message that's visually before the current one is actually after the current + // one for the cursor because the layout is reversed + if (!cursor.moveToPosition(position + 1)) { return null } + return messageDB.readerFor(cursor).current + } + + private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? { + // The message that's visually after the current one is actually before the current + // one for the cursor because the layout is reversed + if (!cursor.moveToPosition(position - 1)) { return null } + return messageDB.readerFor(cursor).current + } + + fun toggleSelection(message: MessageRecord, position: Int) { + if (selectedItems.contains(message)) selectedItems.remove(message) else selectedItems.add(message) + notifyItemChanged(position) + } + + fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { + val cursor = this.cursor + if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null + for (i in 0 until itemCount) { + cursor.moveToPosition(i) + val message = messageDB.readerFor(cursor).current + if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } + } + return null + } + + fun getItemPositionForTimestamp(timestamp: Long): Int? { + val cursor = this.cursor + if (timestamp <= 0L || cursor == null || !isActiveCursor) return null + for (i in 0 until itemCount) { + cursor.moveToPosition(i) + val message = messageDB.readerFor(cursor).current + if (message.dateSent == timestamp) { return i } + } + return null + } + + fun onSearchQueryUpdated(query: String?) { + this.searchQuery = query + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt new file mode 100644 index 0000000000..08b5a02641 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.database.Cursor +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.util.AbstractCursorLoader + +class ConversationLoader(private val threadID: Long, context: Context) : AbstractCursorLoader(context) { + + override fun getCursor(): Cursor { + return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadID) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt new file mode 100644 index 0000000000..1926024015 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRecyclerView.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.VelocityTracker +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.activity_conversation_v2.* +import org.thoughtcrime.securesms.loki.utilities.disableClipping +import org.thoughtcrime.securesms.loki.utilities.toPx +import kotlin.math.abs +import kotlin.math.max + +class ConversationRecyclerView : RecyclerView { + private val maxLongPressVelocityY = toPx(10, resources) + private val minSwipeVelocityX = toPx(10, resources) + private var velocityTracker: VelocityTracker? = null + + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + disableClipping() + } + + override fun onInterceptTouchEvent(e: MotionEvent): Boolean { + val velocityTracker = velocityTracker ?: return super.onInterceptTouchEvent(e) + velocityTracker.computeCurrentVelocity(1000) // Specifying 1000 gives pixels per second + val vx = velocityTracker.xVelocity + val vy = velocityTracker.yVelocity + // Only allow swipes to the left; allowing swipes to the right interferes with some back gestures + if (vx > 0) { return super.onInterceptTouchEvent(e) } + // Distinguish between scrolling gestures and long presses + if (abs(vy) > maxLongPressVelocityY && abs(vx) < minSwipeVelocityX) { return super.onInterceptTouchEvent(e) } + // Return false if abs(v.x) > abs(v.y) so that only swipes that are more horizontal than vertical + // get passed on to the message view + if (abs(vx) > abs(vy)) { + return false + } else { + return super.onInterceptTouchEvent(e) + } + } + + override fun dispatchTouchEvent(e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_DOWN -> velocityTracker = VelocityTracker.obtain() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> velocityTracker = null + } + velocityTracker?.addMovement(e) + return super.dispatchTouchEvent(e) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt new file mode 100644 index 0000000000..33018c4c13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.core.view.children +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.album_thumbnail_view.view.* +import network.loki.messenger.R +import org.session.libsession.utilities.ViewUtil +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.MediaPreviewActivity +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.loki.utilities.ActivityDispatcher +import org.thoughtcrime.securesms.longmessage.LongMessageActivity +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.Slide +import kotlin.math.roundToInt + +class AlbumThumbnailView : FrameLayout { + + companion object { + const val MAX_ALBUM_DISPLAY_SIZE = 5 + } + + // region Lifecycle + constructor(context: Context) : super(context) { + initialize() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + initialize() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + initialize() + } + + private val cornerMask by lazy { CornerMask(this) } + private var slides: List = listOf() + private var slideSize: Int = 0 + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.album_thumbnail_view, this) + } + + override fun dispatchDraw(canvas: Canvas?) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) + } + // endregion + + // region Interaction + + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient) { + val rawXInt = event.rawX.toInt() + val rawYInt = event.rawY.toInt() + val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) + // Z-check in specific order + val testRect = Rect() + // test "Read More" + albumCellBodyTextReadMore.getGlobalVisibleRect(testRect) + if (testRect.contains(eventRect)) { + // dispatch to activity view + ActivityDispatcher.get(context)?.dispatchIntent { context -> + LongMessageActivity.getIntent(context, mms.recipient.address, mms.getId(), true) + } + return + } + // test each album child + albumCellContainer.findViewById(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> + child.getGlobalVisibleRect(testRect) + if (testRect.contains(eventRect)) { + // hit intersects with this particular child + val slide = slides.getOrNull(index) ?: return + // only open to downloaded images + if (slide.isInProgress) return + + ActivityDispatcher.get(context)?.dispatchIntent { context -> + MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient) + } + } + } + } + + fun bind(glideRequests: GlideRequests, message: MmsMessageRecord, + isStart: Boolean, isEnd: Boolean) { + slides = message.slideDeck.thumbnailSlides + if (slides.isEmpty()) { + // this should never be encountered because it's checked by parent + return + } + calculateRadius(isStart, isEnd, message.isOutgoing) + + // recreate cell views if different size to what we have already (for recycling) + if (slides.size != this.slideSize) { + albumCellContainer.removeAllViews() + LayoutInflater.from(context).inflate(layoutRes(slides.size), albumCellContainer) + val overflowed = slides.size > MAX_ALBUM_DISPLAY_SIZE + albumCellContainer.findViewById(R.id.album_cell_overflow_text)?.let { overflowText -> + // overflowText will be null if !overflowed + overflowText.isVisible = overflowed // more than max album size + overflowText.text = context.getString(R.string.AlbumThumbnailView_plus, slides.size - MAX_ALBUM_DISPLAY_SIZE) + } + this.slideSize = slides.size + } + // iterate binding + slides.take(5).forEachIndexed { position, slide -> + val thumbnailView = getThumbnailView(position) + thumbnailView.setImageResource(glideRequests, slide, isPreview = false, mms = message) + } + albumCellBodyParent.isVisible = message.body.isNotEmpty() + albumCellBodyText.text = message.body + post { + // post to await layout of text + albumCellBodyText.layout?.let { layout -> + val maxEllipsis = (0 until layout.lineCount).maxByOrNull { lineNum -> layout.getEllipsisCount(lineNum) } + ?: 0 + // show read more text if at least one line is ellipsized + ViewUtil.setPaddingTop(albumCellBodyTextParent, if (maxEllipsis > 0) resources.getDimension(R.dimen.small_spacing).roundToInt() else resources.getDimension(R.dimen.medium_spacing).roundToInt()) + albumCellBodyTextReadMore.isVisible = maxEllipsis > 0 + } + } + } + + // endregion + + + fun layoutRes(slideCount: Int) = when (slideCount) { + 1 -> R.layout.album_thumbnail_1 // single + 2 -> R.layout.album_thumbnail_2// two sidebyside + 3 -> R.layout.album_thumbnail_3// three stacked + 4 -> R.layout.album_thumbnail_4// four square + 5 -> R.layout.album_thumbnail_5// + else -> R.layout.album_thumbnail_many// five or more + } + + fun getThumbnailView(position: Int): KThumbnailView = when (position) { + 0 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_1) + 1 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_2) + 2 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_3) + 3 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_4) + 4 -> albumCellContainer.findViewById(R.id.albumCellContainer).findViewById(R.id.album_cell_5) + else -> throw Exception("Can't get thumbnail view for non-existent thumbnail at position: $position") + } + + fun calculateRadius(isStart: Boolean, isEnd: Boolean, outgoing: Boolean) { + val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).toInt() + val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).toInt() + val (startTop, endTop, startBottom, endBottom) = when { + // single message, consistent dimen + isStart && isEnd -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen) + // start of message cluster, collapsed BL + isStart -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen) + // end of message cluster, collapsed TL + isEnd -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen) + // else in the middle, no rounding left side + else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen) + } + // TL, TR, BR, BL (CW direction) + cornerMask.setRadii( + if (!outgoing) startTop else endTop, // TL + if (!outgoing) endTop else startTop, // TR + if (!outgoing) endBottom else startBottom, // BR + if (!outgoing) startBottom else endBottom // BL + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java similarity index 98% rename from app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java index 65cad0a274..5a04e77ac2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ExpirationTimerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components; +package org.thoughtcrime.securesms.conversation.v2.components; import android.content.Context; import androidx.annotation.NonNull; @@ -118,5 +118,4 @@ public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImag Util.runOnMainDelayed(this, timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn)); } } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt new file mode 100644 index 0000000000..23c0add0ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_link_preview_draft.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.ImageSlide + +class LinkPreviewDraftView : LinearLayout { + var delegate: LinkPreviewDraftViewDelegate? = null + + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + // Start out with the loader showing and the content view hidden + LayoutInflater.from(context).inflate(R.layout.view_link_preview_draft, this) + linkPreviewDraftContainer.isVisible = false + thumbnailImageView.clipToOutline = true + linkPreviewDraftCancelButton.setOnClickListener { cancel() } + } + + fun update(glide: GlideRequests, linkPreview: LinkPreview) { + // Hide the loader and show the content view + linkPreviewDraftContainer.isVisible = true + linkPreviewDraftLoader.isVisible = false + thumbnailImageView.radius = toPx(4, resources) + if (linkPreview.getThumbnail().isPresent) { + // This internally fetches the thumbnail + thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) + } + linkPreviewDraftTitleTextView.text = linkPreview.title + } + + private fun cancel() { + delegate?.cancelLinkPreviewDraft() + } +} + +interface LinkPreviewDraftViewDelegate { + + fun cancelLinkPreviewDraft() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/OpenGroupGuidelinesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt similarity index 68% rename from app/src/main/java/org/thoughtcrime/securesms/loki/views/OpenGroupGuidelinesView.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt index d29460866c..e4d7e4a3b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/OpenGroupGuidelinesView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/OpenGroupGuidelinesView.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.loki.views +package org.thoughtcrime.securesms.conversation.v2.components import android.content.Context import android.content.Intent @@ -7,30 +7,22 @@ import android.view.LayoutInflater import android.widget.FrameLayout import kotlinx.android.synthetic.main.view_open_group_guidelines.view.* import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.loki.activities.OpenGroupGuidelinesActivity import org.thoughtcrime.securesms.loki.utilities.push class OpenGroupGuidelinesView : FrameLayout { - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { + private fun initialize() { val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater val contentView = inflater.inflate(R.layout.view_open_group_guidelines, null) addView(contentView) readButton.setOnClickListener { - val activity = context as ConversationActivity + val activity = context as ConversationActivityV2 val intent = Intent(activity, OpenGroupGuidelinesActivity::class.java) activity.push(intent) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java similarity index 93% rename from app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java index 477776a9dd..826cfe7b3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components; +package org.thoughtcrime.securesms.conversation.v2.components; import android.content.Context; import android.content.res.TypedArray; @@ -13,18 +13,14 @@ import android.widget.LinearLayout; import network.loki.messenger.R; public class TypingIndicatorView extends LinearLayout { + private boolean isActive; + private long startTime; - private static final long DURATION = 300; - private static final long PRE_DELAY = 500; - private static final long POST_DELAY = 500; private static final long CYCLE_DURATION = 1500; private static final long DOT_DURATION = 600; private static final float MIN_ALPHA = 0.4f; private static final float MIN_SCALE = 0.75f; - private boolean isActive; - private long startTime; - private View dot1; private View dot2; private View dot3; @@ -40,7 +36,7 @@ public class TypingIndicatorView extends LinearLayout { } private void initialize(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.typing_indicator_view, this); + inflate(getContext(), R.layout.view_typing_indicator, this); setWillNotDraw(false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt new file mode 100644 index 0000000000..0628b63b78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import kotlinx.android.synthetic.main.view_conversation_typing_container.view.* +import network.loki.messenger.R +import org.session.libsession.utilities.recipients.Recipient + +class TypingIndicatorViewContainer : LinearLayout { + + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_conversation_typing_container, this) + } + + fun setTypists(typists: List) { + if (typists.isEmpty()) { typingIndicator.stopAnimation(); return } + typingIndicator.startAnimation() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt new file mode 100644 index 0000000000..3013ab8901 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.conversation.v2.dialogs + +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import kotlinx.android.synthetic.main.dialog_blocked.view.* +import kotlinx.android.synthetic.main.dialog_blocked.view.cancelButton +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.database.DatabaseFactory + +/** Shown upon sending a message to a user that's blocked. */ +class BlockedDialog(private val recipient: Recipient) : BaseDialog() { + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_blocked, null) + val contactDB = DatabaseFactory.getSessionContactDatabase(requireContext()) + val sessionID = recipient.address.toString() + val contact = contactDB.getContactWithSessionID(sessionID) + val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val title = resources.getString(R.string.dialog_blocked_title, name) + contentView.blockedTitleTextView.text = title + val explanation = resources.getString(R.string.dialog_blocked_explanation, name) + val spannable = SpannableStringBuilder(explanation) + val startIndex = explanation.indexOf(name) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + contentView.blockedExplanationTextView.text = spannable + contentView.cancelButton.setOnClickListener { dismiss() } + contentView.unblockButton.setOnClickListener { unblock() } + builder.setView(contentView) + } + + private fun unblock() { + DatabaseFactory.getRecipientDatabase(requireContext()).setBlocked(recipient, false) + dismiss() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt new file mode 100644 index 0000000000..fe0423e2e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.conversation.v2.dialogs + +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import kotlinx.android.synthetic.main.dialog_download.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.database.DatabaseFactory + +/** Shown when receiving media from a contact for the first time, to confirm that + * they are to be trusted and files sent by them are to be downloaded. */ +class DownloadDialog(private val recipient: Recipient) : BaseDialog() { + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_download, null) + val contactDB = DatabaseFactory.getSessionContactDatabase(requireContext()) + val sessionID = recipient.address.toString() + val contact = contactDB.getContactWithSessionID(sessionID) + val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val title = resources.getString(R.string.dialog_download_title, name) + contentView.downloadTitleTextView.text = title + val explanation = resources.getString(R.string.dialog_download_explanation, name) + val spannable = SpannableStringBuilder(explanation) + val startIndex = explanation.indexOf(name) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + contentView.downloadExplanationTextView.text = spannable + contentView.cancelButton.setOnClickListener { dismiss() } + contentView.downloadButton.setOnClickListener { trust() } + builder.setView(contentView) + } + + private fun trust() { + // TODO: Implement + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt new file mode 100644 index 0000000000..51d85c3651 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.conversation.v2.dialogs + +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.synthetic.main.dialog_join_open_group.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.utilities.OpenGroupUrlParser +import org.session.libsignal.utilities.ThreadUtils +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.loki.api.OpenGroupManager +import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol + +/** Shown upon tapping an open group invitation. */ +class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() { + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_join_open_group, null) + val title = resources.getString(R.string.dialog_join_open_group_title, name) + contentView.joinOpenGroupTitleTextView.text = title + val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) + val spannable = SpannableStringBuilder(explanation) + val startIndex = explanation.indexOf(name) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + contentView.joinOpenGroupExplanationTextView.text = spannable + contentView.cancelButton.setOnClickListener { dismiss() } + contentView.joinButton.setOnClickListener { join() } + builder.setView(contentView) + } + + private fun join() { + val openGroup = OpenGroupUrlParser.parseUrl(url) + val activity = requireContext() as AppCompatActivity + ThreadUtils.queue { + OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) + MultiDeviceProtocol.forceSyncConfigurationNowIfNeeded(activity) + } + dismiss() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt new file mode 100644 index 0000000000..f9fa6c381e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.conversation.v2.dialogs + +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import kotlinx.android.synthetic.main.dialog_link_preview.view.* +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog + +/** Shown the first time the user inputs a URL that could generate a link preview, to + * let them know that Session offers the ability to send and receive link previews. */ +class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_link_preview, null) + contentView.cancelButton.setOnClickListener { dismiss() } + contentView.enableLinkPreviewsButton.setOnClickListener { enable() } + builder.setView(contentView) + } + + private fun enable() { + TextSecurePreferences.setLinkPreviewsEnabled(requireContext(), true) + dismiss() + onEnabled() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/OpenURLDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/OpenURLDialog.kt new file mode 100644 index 0000000000..ea0230f578 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/OpenURLDialog.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.conversation.v2.dialogs + +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import kotlinx.android.synthetic.main.dialog_open_url.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog + +/** Shown upon tapping a URL. */ +class OpenURLDialog(private val url: String) : BaseDialog() { + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_open_url, null) + val explanation = resources.getString(R.string.dialog_open_url_explanation, url) + val spannable = SpannableStringBuilder(explanation) + val startIndex = explanation.indexOf(url) + spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + url.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + contentView.openURLExplanationTextView.text = spannable + contentView.cancelButton.setOnClickListener { dismiss() } + contentView.openURLButton.setOnClickListener { open() } + builder.setView(contentView) + } + + private fun open() { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + requireContext().startActivity(intent) + } catch (e: Exception) { + Toast.makeText(context, R.string.invalid_url, Toast.LENGTH_SHORT).show() + } + dismiss() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt new file mode 100644 index 0000000000..cfb1e38726 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -0,0 +1,196 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar + +import android.content.Context +import android.content.res.Resources +import android.text.InputType +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_input_bar.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftView +import org.thoughtcrime.securesms.conversation.v2.components.LinkPreviewDraftViewDelegate +import org.thoughtcrime.securesms.conversation.v2.messages.QuoteView +import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.loki.utilities.toDp +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.mms.GlideRequests +import kotlin.math.max +import kotlin.math.roundToInt + +class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate { + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels + private val vMargin by lazy { toDp(4, resources) } + private val minHeight by lazy { toPx(56, resources) } + private var linkPreviewDraftView: LinkPreviewDraftView? = null + var delegate: InputBarDelegate? = null + var additionalContentHeight = 0 + var quote: MessageRecord? = null + var linkPreview: LinkPreview? = null + var showInput: Boolean = true + set(value) { field = value; showOrHideInputIfNeeded() } + + var text: String + get() { return inputBarEditText.text.toString() } + set(value) { inputBarEditText.setText(value) } + + private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24) } + private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone) } + private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true) } + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_input_bar, this) + // Attachments button + attachmentsButtonContainer.addView(attachmentsButton) + attachmentsButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + attachmentsButton.onPress = { toggleAttachmentOptions() } + // Microphone button + microphoneOrSendButtonContainer.addView(microphoneButton) + microphoneButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + microphoneButton.onLongPress = { startRecordingVoiceMessage() } + microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) } + microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) } + microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) } + // Send button + microphoneOrSendButtonContainer.addView(sendButton) + sendButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT) + sendButton.isVisible = false + sendButton.onUp = { delegate?.sendMessage() } + // Edit text + inputBarEditText.imeOptions = inputBarEditText.imeOptions or 16777216 // Always use incognito keyboard + inputBarEditText.inputType = inputBarEditText.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + inputBarEditText.delegate = this + } + // endregion + + // region General + private fun setHeight(newHeight: Int) { + val layoutParams = inputBarLinearLayout.layoutParams as LayoutParams + layoutParams.height = newHeight + inputBarLinearLayout.layoutParams = layoutParams + delegate?.inputBarHeightChanged(newHeight) + } + // endregion + + // region Updating + override fun inputBarEditTextContentChanged(text: CharSequence) { + sendButton.isVisible = text.isNotEmpty() + microphoneButton.isVisible = text.isEmpty() + delegate?.inputBarEditTextContentChanged(text) + } + + override fun inputBarEditTextHeightChanged(newValue: Int) { + val newHeight = max(newValue + 2 * vMargin, minHeight) + inputBarAdditionalContentContainer.height + setHeight(newHeight) + } + + private fun toggleAttachmentOptions() { + delegate?.toggleAttachmentOptions() + } + + private fun startRecordingVoiceMessage() { + delegate?.startRecordingVoiceMessage() + } + + // Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft + // a quote and a link preview at the same time. + + fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { + quote = message + linkPreview = null + linkPreviewDraftView = null + inputBarAdditionalContentContainer.removeAllViews() + val quoteView = QuoteView(context, QuoteView.Mode.Draft) + quoteView.delegate = this + inputBarAdditionalContentContainer.addView(quoteView) + val attachments = (message as? MmsMessageRecord)?.slideDeck + // The max content width is the screen width - 2 times the horizontal input bar padding - the + // quote view content area's start and end margins. This unfortunately has to be calculated manually + // here to get the layout right. + val maxContentWidth = (screenWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources) - toPx(30, resources)).roundToInt() + val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() + quoteView.bind(sender, message.body, attachments, + thread, true, maxContentWidth, message.isOpenGroupInvitation, message.threadId, glide) + // The 6 DP below is the padding the quote view applies to itself, which isn't included in the + // intrinsic height calculation. + val quoteViewIntrinsicHeight = quoteView.getIntrinsicHeight(maxContentWidth) + toPx(6, resources) + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + quoteViewIntrinsicHeight + additionalContentHeight = quoteViewIntrinsicHeight + setHeight(newHeight) + } + + override fun cancelQuoteDraft() { + quote = null + inputBarAdditionalContentContainer.removeAllViews() + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + additionalContentHeight = 0 + setHeight(newHeight) + } + + fun draftLinkPreview() { + quote = null + val linkPreviewDraftHeight = toPx(88, resources) + inputBarAdditionalContentContainer.removeAllViews() + val linkPreviewDraftView = LinkPreviewDraftView(context) + linkPreviewDraftView.delegate = this + this.linkPreviewDraftView = linkPreviewDraftView + inputBarAdditionalContentContainer.addView(linkPreviewDraftView) + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + linkPreviewDraftHeight + additionalContentHeight = linkPreviewDraftHeight + setHeight(newHeight) + } + + fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) { + this.linkPreview = linkPreview + val linkPreviewDraftView = this.linkPreviewDraftView ?: return + linkPreviewDraftView.update(glide, linkPreview) + } + + override fun cancelLinkPreviewDraft() { + if (quote != null) { return } + linkPreview = null + inputBarAdditionalContentContainer.removeAllViews() + val newHeight = max(inputBarEditText.height + 2 * vMargin, minHeight) + additionalContentHeight = 0 + setHeight(newHeight) + } + + private fun showOrHideInputIfNeeded() { + if (showInput) { + setOf( inputBarEditText, attachmentsButton ).forEach { it.isVisible = true } + microphoneButton.isVisible = text.isEmpty() + sendButton.isVisible = text.isNotEmpty() + } else { + cancelQuoteDraft() + cancelLinkPreviewDraft() + val views = setOf( inputBarEditText, attachmentsButton, microphoneButton, sendButton ) + views.forEach { it.isVisible = false } + } + } + // endregion +} + +interface InputBarDelegate { + + fun inputBarHeightChanged(newValue: Int) + fun inputBarEditTextContentChanged(newContent: CharSequence) + fun toggleAttachmentOptions() + fun showVoiceMessageUI() + fun startRecordingVoiceMessage() + fun onMicrophoneButtonMove(event: MotionEvent) + fun onMicrophoneButtonCancel(event: MotionEvent) + fun onMicrophoneButtonUp(event: MotionEvent) + fun sendMessage() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt new file mode 100644 index 0000000000..ef1417ea42 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar + +import android.animation.PointFEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.PointF +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.util.Log +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.annotation.DrawableRes +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +import org.thoughtcrime.securesms.loki.utilities.* +import org.thoughtcrime.securesms.loki.views.GlowViewUtilities +import org.thoughtcrime.securesms.loki.views.InputBarButtonImageViewContainer +import java.util.* +import kotlin.math.abs + +class InputBarButton : RelativeLayout { + private val gestureHandler = Handler(Looper.getMainLooper()) + private var isSendButton = false + private var hasOpaqueBackground = false + private var isGIFButton = false + @DrawableRes private var iconID = 0 + private var longPressCallback: Runnable? = null + private var onDownTimestamp = 0L + var snIsEnabled = true + var onPress: (() -> Unit)? = null + var onMove: ((MotionEvent) -> Unit)? = null + var onCancel: ((MotionEvent) -> Unit)? = null + var onUp: ((MotionEvent) -> Unit)? = null + var onLongPress: (() -> Unit)? = null + + companion object { + const val animationDuration = 250.toLong() + const val longPressDurationThreshold = 250L // ms + } + + private val expandedImageViewPosition by lazy { PointF(0.0f, 0.0f) } + private val collapsedImageViewPosition by lazy { PointF((expandedSize - collapsedSize) / 2, (expandedSize - collapsedSize) / 2) } + private val colorID by lazy { + if (hasOpaqueBackground) { + R.color.input_bar_button_background_opaque + } else if (isSendButton) { + R.color.accent + } else { + R.color.input_bar_button_background + } + } + + val expandedSize by lazy { resources.getDimension(R.dimen.input_bar_button_expanded_size) } + val collapsedSize by lazy { resources.getDimension(R.dimen.input_bar_button_collapsed_size) } + + private val imageViewContainer by lazy { + val result = InputBarButtonImageViewContainer(context) + val size = collapsedSize.toInt() + result.layoutParams = LayoutParams(size, size) + result.setBackgroundResource(R.drawable.input_bar_button_background) + result.mainColor = resources.getColorWithID(colorID, context.theme) + if (hasOpaqueBackground) { + result.strokeColor = resources.getColorWithID(R.color.input_bar_button_background_opaque_border, context.theme) + } + result + } + + private val imageView by lazy { + val result = ImageView(context) + val size = if (isGIFButton) toPx(24, resources) else toPx(16, resources) + result.layoutParams = LayoutParams(size, size) + result.scaleType = ImageView.ScaleType.CENTER_INSIDE + result.setImageResource(iconID) + val colorID = if (isSendButton) R.color.black else R.color.text + result.imageTintList = ColorStateList.valueOf(resources.getColorWithID(colorID, context.theme)) + result + } + + constructor(context: Context) : super(context) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessException("Use InputBarButton(context:iconID:) instead.") } + + constructor(context: Context, @DrawableRes iconID: Int, isSendButton: Boolean = false, + hasOpaqueBackground: Boolean = false, isGIFButton: Boolean = false) : super(context) { + this.isSendButton = isSendButton + this.iconID = iconID + this.hasOpaqueBackground = hasOpaqueBackground + this.isGIFButton = isGIFButton + val size = resources.getDimension(R.dimen.input_bar_button_expanded_size).toInt() + val layoutParams = LayoutParams(size, size) + this.layoutParams = layoutParams + addView(imageViewContainer) + imageViewContainer.x = collapsedImageViewPosition.x + imageViewContainer.y = collapsedImageViewPosition.y + imageViewContainer.addView(imageView) + val imageViewLayoutParams = imageView.layoutParams as LayoutParams + imageViewLayoutParams.addRule(RelativeLayout.CENTER_IN_PARENT) + imageView.layoutParams = imageViewLayoutParams + gravity = Gravity.TOP or Gravity.LEFT // Intentionally not Gravity.START + isHapticFeedbackEnabled = true + } + + fun expand() { + GlowViewUtilities.animateColorChange(context, imageViewContainer, colorID, R.color.accent) + imageViewContainer.animateSizeChange(R.dimen.input_bar_button_collapsed_size, R.dimen.input_bar_button_expanded_size, animationDuration) + animateImageViewContainerPositionChange(collapsedImageViewPosition, expandedImageViewPosition) + } + + fun collapse() { + GlowViewUtilities.animateColorChange(context, imageViewContainer, R.color.accent, colorID) + imageViewContainer.animateSizeChange(R.dimen.input_bar_button_expanded_size, R.dimen.input_bar_button_collapsed_size, animationDuration) + animateImageViewContainerPositionChange(expandedImageViewPosition, collapsedImageViewPosition) + } + + private fun animateImageViewContainerPositionChange(startPosition: PointF, endPosition: PointF) { + val animation = ValueAnimator.ofObject(PointFEvaluator(), startPosition, endPosition) + animation.duration = animationDuration + animation.addUpdateListener { animator -> + val point = animator.animatedValue as PointF + imageViewContainer.x = point.x + imageViewContainer.y = point.y + } + animation.start() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!snIsEnabled) { return false } + when (event.action) { + MotionEvent.ACTION_DOWN -> onDown(event) + MotionEvent.ACTION_MOVE -> onMove(event) + MotionEvent.ACTION_UP -> onUp(event) + MotionEvent.ACTION_CANCEL -> onCancel(event) + } + return true + } + + private fun onDown(event: MotionEvent) { + expand() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } else { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + val newLongPressCallback = Runnable { onLongPress?.invoke() } + this.longPressCallback = newLongPressCallback + gestureHandler.postDelayed(newLongPressCallback, InputBarButton.longPressDurationThreshold) + onDownTimestamp = Date().time + } + + private fun onMove(event: MotionEvent) { + onMove?.invoke(event) + } + + private fun onCancel(event: MotionEvent) { + onCancel?.invoke(event) + collapse() + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + } + + private fun onUp(event: MotionEvent) { + onUp?.invoke(event) + collapse() + if ((Date().time - onDownTimestamp) < InputBarButton.longPressDurationThreshold) { + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + onPress?.invoke() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt new file mode 100644 index 0000000000..f2d3e5eded --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarEditText.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar + +import android.content.Context +import android.content.res.Resources +import android.text.Layout +import android.text.StaticLayout +import android.util.AttributeSet +import android.util.Log +import android.widget.RelativeLayout +import androidx.appcompat.widget.AppCompatEditText +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities +import org.thoughtcrime.securesms.loki.utilities.toPx +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +class InputBarEditText : AppCompatEditText { + private val screenWidth get() = Resources.getSystem().displayMetrics.widthPixels + var delegate: InputBarEditTextDelegate? = null + + private val snMinHeight = toPx(40.0f, resources) + private val snMaxHeight = toPx(80.0f, resources) + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + override fun onTextChanged(text: CharSequence, start: Int, lengthBefore: Int, lengthAfter: Int) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + delegate?.inputBarEditTextContentChanged(text) + // Calculate the width manually to get it right even before layout has happened (i.e. + // when restoring a draft). The 64 DP is the horizontal margin around the input bar + // edit text. + val width = (screenWidth - 2 * toPx(64.0f, resources)).roundToInt() + if (width < 0) { return } // screenWidth initially evaluates to 0 + val height = TextUtilities.getIntrinsicHeight(text, paint, width).toFloat() + val constrainedHeight = min(max(height, snMinHeight), snMaxHeight) + if (constrainedHeight.roundToInt() == this.height) { return } + val layoutParams = this.layoutParams as? RelativeLayout.LayoutParams ?: return + layoutParams.height = constrainedHeight.roundToInt() + this.layoutParams = layoutParams + delegate?.inputBarEditTextHeightChanged(constrainedHeight.roundToInt()) + } +} + +interface InputBarEditTextDelegate { + + fun inputBarEditTextContentChanged(text: CharSequence) + fun inputBarEditTextHeightChanged(newValue: Int) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt new file mode 100644 index 0000000000..0a210d6ae5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar + +import android.animation.FloatEvaluator +import android.animation.IntEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.RelativeLayout +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_input_bar_recording.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.loki.utilities.animateSizeChange +import org.thoughtcrime.securesms.loki.utilities.disableClipping +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.util.DateUtils +import java.util.* + +class InputBarRecordingView : RelativeLayout { + private var startTimestamp = 0L + private val snHandler = Handler(Looper.getMainLooper()) + private var dotViewAnimation: ValueAnimator? = null + private var pulseAnimation: ValueAnimator? = null + var delegate: InputBarRecordingViewDelegate? = null + + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_input_bar_recording, this) + inputBarMiddleContentContainer.disableClipping() + inputBarCancelButton.setOnClickListener { hide() } + } + + fun show() { + startTimestamp = Date().time + recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) + inputBarCancelButton.alpha = 0.0f + inputBarMiddleContentContainer.alpha = 1.0f + lockView.alpha = 1.0f + isVisible = true + alpha = 0.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + alpha = animator.animatedValue as Float + } + animation.start() + animateDotView() + pulse() + animateLockViewUp() + updateTimer() + } + + fun hide() { + alpha = 1.0f + val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) + animation.duration = 250L + animation.addUpdateListener { animator -> + alpha = animator.animatedValue as Float + if (animator.animatedFraction == 1.0f) { + isVisible = false + dotViewAnimation?.repeatCount = 0 + pulseAnimation?.removeAllUpdateListeners() + } + } + animation.start() + delegate?.handleVoiceMessageUIHidden() + } + + private fun animateDotView() { + val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) + dotViewAnimation = animation + animation.duration = 500L + animation.addUpdateListener { animator -> + dotView.alpha = animator.animatedValue as Float + } + animation.repeatCount = ValueAnimator.INFINITE + animation.repeatMode = ValueAnimator.REVERSE + animation.start() + } + + private fun pulse() { + val collapsedSize = toPx(80.0f, resources) + val expandedSize = toPx(104.0f, resources) + pulseView.animateSizeChange(collapsedSize, expandedSize, 1000) + val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f) + pulseAnimation = animation + animation.duration = 1000L + animation.addUpdateListener { animator -> + pulseView.alpha = animator.animatedValue as Float + if (animator.animatedFraction == 1.0f && isVisible) { pulse() } + } + animation.start() + } + + private fun animateLockViewUp() { + val startMarginBottom = toPx(32, resources) + val endMarginBottom = toPx(72, resources) + val layoutParams = lockView.layoutParams as LayoutParams + layoutParams.bottomMargin = startMarginBottom + lockView.layoutParams = layoutParams + val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom) + animation.duration = 250L + animation.addUpdateListener { animator -> + layoutParams.bottomMargin = animator.animatedValue as Int + lockView.layoutParams = layoutParams + } + animation.start() + } + + private fun updateTimer() { + val duration = (Date().time - startTimestamp) / 1000L + recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration) + snHandler.postDelayed({ updateTimer() }, 500) + } + + fun lock() { + val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) + fadeOutAnimation.duration = 250L + fadeOutAnimation.addUpdateListener { animator -> + inputBarMiddleContentContainer.alpha = animator.animatedValue as Float + lockView.alpha = animator.animatedValue as Float + } + fadeOutAnimation.start() + val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f) + fadeInAnimation.duration = 250L + fadeInAnimation.addUpdateListener { animator -> + inputBarCancelButton.alpha = animator.animatedValue as Float + } + fadeInAnimation.start() + recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme)) + recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() } + inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() } + } +} + +interface InputBarRecordingViewDelegate { + + fun handleVoiceMessageUIHidden() + fun sendVoiceMessage() + fun cancelVoiceMessage() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt new file mode 100644 index 0000000000..2159a5dac8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.RelativeLayout +import kotlinx.android.synthetic.main.view_mention_candidate.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.mentions.Mention +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.thoughtcrime.securesms.mms.GlideRequests + +class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : RelativeLayout(context, attrs, defStyleAttr) { + var candidate = Mention("", "") + set(newValue) { field = newValue; update() } + var glide: GlideRequests? = null + var openGroupServer: String? = null + var openGroupRoom: String? = null + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context) : this(context, null) + + companion object { + + fun inflate(layoutInflater: LayoutInflater, parent: ViewGroup): MentionCandidateView { + return layoutInflater.inflate(R.layout.view_mention_candidate_v2, parent, false) as MentionCandidateView + } + } + + private fun update() { + mentionCandidateNameTextView.text = candidate.displayName + profilePictureView.publicKey = candidate.publicKey + profilePictureView.displayName = candidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.glide = glide!! + profilePictureView.update() + if (openGroupServer != null && openGroupRoom != null) { + val isUserModerator = OpenGroupAPIV2.isUserModerator(candidate.publicKey, openGroupRoom!!, openGroupServer!!) + moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE + } else { + moderatorIconImageView.visibility = View.GONE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt new file mode 100644 index 0000000000..bbf97f0afd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.conversation.v2.input_bar.mentions + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ListView +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.mms.GlideRequests +import org.session.libsession.messaging.mentions.Mention + +class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ListView(context, attrs, defStyleAttr) { + private var candidates = listOf() + set(newValue) { field = newValue; snAdapter.candidates = newValue } + var glide: GlideRequests? = null + set(newValue) { field = newValue; snAdapter.glide = newValue } + var openGroupServer: String? = null + set(newValue) { field = newValue; snAdapter.openGroupServer = openGroupServer } + var openGroupRoom: String? = null + set(newValue) { field = newValue; snAdapter.openGroupRoom = openGroupRoom } + var onCandidateSelected: ((Mention) -> Unit)? = null + + private val snAdapter by lazy { Adapter(context) } + + private class Adapter(private val context: Context) : BaseAdapter() { + var candidates = listOf() + set(newValue) { field = newValue; notifyDataSetChanged() } + var glide: GlideRequests? = null + var openGroupServer: String? = null + var openGroupRoom: String? = null + + override fun getCount(): Int { return candidates.count() } + override fun getItemId(position: Int): Long { return position.toLong() } + override fun getItem(position: Int): Mention { return candidates[position] } + + override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { + val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView.inflate(LayoutInflater.from(context), parent) + val mentionCandidate = getItem(position) + cell.glide = glide + cell.candidate = mentionCandidate + cell.openGroupServer = openGroupServer + cell.openGroupRoom = openGroupRoom + return cell + } + } + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context) : this(context, null) + + init { + clipToOutline = true + adapter = snAdapter + snAdapter.candidates = candidates + setOnItemClickListener { _, _, position, _ -> + onCandidateSelected?.invoke(candidates[position]) + } + } + + fun show(candidates: List, threadID: Long) { + val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID) + if (openGroup != null) { + openGroupServer = openGroup.server + openGroupRoom = openGroup.room + } + setMentionCandidates(candidates) + } + + fun setMentionCandidates(candidates: List) { + this.candidates = candidates + val layoutParams = this.layoutParams as ViewGroup.LayoutParams + layoutParams.height = toPx(Math.min(candidates.count(), 4) * 44, resources) + this.layoutParams = layoutParams + } + + fun hide() { + val layoutParams = this.layoutParams as ViewGroup.LayoutParams + layoutParams.height = 0 + this.layoutParams = layoutParams + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt new file mode 100644 index 0000000000..42e36e2c78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.conversation.v2.menus + +import android.content.Context +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import network.loki.messenger.R +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord + +class ConversationActionModeCallback(private val adapter: ConversationAdapter, private val threadID: Long, + private val context: Context) : ActionMode.Callback { + var delegate: ConversationActionModeCallbackDelegate? = null + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + val inflater = mode.menuInflater + inflater.inflate(R.menu.menu_conversation_item_action, menu) + updateActionModeMenu(menu) + return true + } + + fun updateActionModeMenu(menu: Menu) { + // Prepare + val selectedItems = adapter.selectedItems + val containsControlMessage = selectedItems.any { it.isUpdate } + val hasText = selectedItems.any { it.body.isNotEmpty() } + if (selectedItems.isEmpty()) { return } + val firstMessage = selectedItems.iterator().next() + val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID) + val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! + fun userCanDeleteSelectedItems(): Boolean { + if (openGroup == null) { return true } + val allSentByCurrentUser = selectedItems.all { it.isOutgoing } + if (allSentByCurrentUser) { return true } + return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) + } + fun userCanBanSelectedUsers(): Boolean { + if (openGroup == null) { return false } + val anySentByCurrentUser = selectedItems.any { it.isOutgoing } + if (anySentByCurrentUser) { return false } // Users can't ban themselves + val selectedUsers = selectedItems.map { it.recipient.address.toString() }.toSet() + if (selectedUsers.size > 1) { return false } + return OpenGroupAPIV2.isUserModerator(userPublicKey, openGroup.room, openGroup.server) + } + // Delete message + menu.findItem(R.id.menu_context_delete_message).isVisible = userCanDeleteSelectedItems() + // Ban user + menu.findItem(R.id.menu_context_ban_user).isVisible = userCanBanSelectedUsers() + // Copy message text + menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText + // Copy Session ID + menu.findItem(R.id.menu_context_copy_public_key).isVisible = + (openGroup != null && selectedItems.size == 1 && firstMessage.recipient.address.toString() != userPublicKey) + // Resend + menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) + // Save media + menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1 + && firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide()) + // Reply + menu.findItem(R.id.menu_context_reply).isVisible = + (selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed) + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean { + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + val selectedItems = adapter.selectedItems + when (item.itemId) { + R.id.menu_context_delete_message -> delegate?.deleteMessages(selectedItems) + R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) + R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) + R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) + R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) + R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems) + R.id.menu_context_reply -> delegate?.reply(selectedItems) + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + adapter.selectedItems.clear() + adapter.notifyDataSetChanged() + } +} + +interface ConversationActionModeCallbackDelegate { + + fun deleteMessages(messages: Set) + fun banUser(messages: Set) + fun copyMessages(messages: Set) + fun copySessionID(messages: Set) + fun resendMessage(messages: Set) + fun saveAttachment(messages: Set) + fun reply(messages: Set) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt new file mode 100644 index 0000000000..5c26c4d540 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -0,0 +1,328 @@ +package org.thoughtcrime.securesms.conversation.v2.menus + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.os.AsyncTask +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.ColorInt +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SearchView.OnQueryTextListener +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import kotlinx.android.synthetic.main.activity_conversation_v2.* +import network.loki.messenger.R +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.leave +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.* +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity +import org.thoughtcrime.securesms.loki.activities.EditClosedGroupActivity.Companion.groupIDKey +import org.thoughtcrime.securesms.loki.activities.SelectContactsActivity +import org.thoughtcrime.securesms.loki.utilities.getColorWithID +import org.thoughtcrime.securesms.util.BitmapUtil +import java.io.IOException + +object ConversationMenuHelper { + + fun onPrepareOptionsMenu(menu: Menu, inflater: MenuInflater, thread: Recipient, threadId: Long, context: Context, onOptionsItemSelected: (MenuItem) -> Unit) { + // Prepare + menu.clear() + val isOpenGroup = thread.isOpenGroupRecipient + // Base menu (options that should always be present) + inflater.inflate(R.menu.menu_conversation, menu) + // Expiring messages + if (!isOpenGroup) { + if (thread.expireMessages > 0) { + inflater.inflate(R.menu.menu_conversation_expiration_on, menu) + val item = menu.findItem(R.id.menu_expiring_messages) + val actionView = item.actionView + val iconView = actionView.findViewById(R.id.menu_badge_icon) + val badgeView = actionView.findViewById(R.id.expiration_badge) + @ColorInt val color = context.resources.getColorWithID(R.color.text, context.theme) + iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) + badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) + actionView.setOnClickListener { onOptionsItemSelected(item) } + } else { + inflater.inflate(R.menu.menu_conversation_expiration_off, menu) + } + } + // One-on-one chat menu (options that should only be present for one-on-one chats) + if (thread.isContactRecipient) { + if (thread.isBlocked) { + inflater.inflate(R.menu.menu_conversation_unblock, menu) + } else { + inflater.inflate(R.menu.menu_conversation_block, menu) + } + inflater.inflate(R.menu.menu_conversation_copy_session_id, menu) + } + // Closed group menu (options that should only be present in closed groups) + if (thread.isClosedGroupRecipient) { + inflater.inflate(R.menu.menu_conversation_closed_group, menu) + } + // Open group menu + if (isOpenGroup) { + inflater.inflate(R.menu.menu_conversation_open_group, menu) + } + // Muting + if (thread.isMuted) { + inflater.inflate(R.menu.menu_conversation_muted, menu) + } else { + inflater.inflate(R.menu.menu_conversation_unmuted, menu) + } + + // Search + val searchViewItem = menu.findItem(R.id.menu_search) + (context as ConversationActivityV2).searchViewItem = searchViewItem + val searchView = searchViewItem.actionView as SearchView + val searchViewModel = context.searchViewModel!! + val queryListener = object : OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(query: String): Boolean { + searchViewModel.onQueryUpdated(query, threadId) + context.searchBottomBar.showLoading() + context.onSearchQueryUpdated(query) + return true + } + } + searchViewItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + searchView.setOnQueryTextListener(queryListener) + searchViewModel.onSearchOpened() + context.searchBottomBar.visibility = View.VISIBLE + context.searchBottomBar.setData(0, 0) + context.inputBar.visibility = View.GONE + for (i in 0 until menu.size()) { + if (menu.getItem(i) != searchViewItem) { + menu.getItem(i).isVisible = false + } + } + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + searchView.setOnQueryTextListener(null) + searchViewModel.onSearchClosed() + context.searchBottomBar.visibility = View.GONE + context.inputBar.visibility = View.VISIBLE + context.onSearchQueryUpdated(null) + context.invalidateOptionsMenu() + return true + } + }) + } + + fun onOptionItemSelected(context: Context, item: MenuItem, thread: Recipient): Boolean { + when (item.itemId) { + R.id.menu_view_all_media -> { showAllMedia(context, thread) } + R.id.menu_search -> { search(context) } + R.id.menu_add_shortcut -> { addShortcut(context, thread) } + R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } + R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) } + R.id.menu_unblock -> { unblock(context, thread) } + R.id.menu_block -> { block(context, thread) } + R.id.menu_copy_session_id -> { copySessionID(context, thread) } + R.id.menu_edit_group -> { editClosedGroup(context, thread) } + R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } + R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } + R.id.menu_unmute_notifications -> { unmute(context, thread) } + R.id.menu_mute_notifications -> { mute(context, thread) } + } + return true + } + + private fun showAllMedia(context: Context, thread: Recipient) { + val intent = Intent(context, MediaOverviewActivity::class.java) + intent.putExtra(MediaOverviewActivity.ADDRESS_EXTRA, thread.address) + val activity = context as AppCompatActivity + activity.startActivity(intent) + } + + private fun search(context: Context) { + val searchViewModel = (context as ConversationActivityV2).searchViewModel!! + searchViewModel.onSearchOpened() + } + + @SuppressLint("StaticFieldLeak") + private fun addShortcut(context: Context, thread: Recipient) { + object : AsyncTask() { + + override fun doInBackground(vararg params: Void?): IconCompat? { + var icon: IconCompat? = null + val contactPhoto = thread.contactPhoto + if (contactPhoto != null) { + try { + var bitmap = BitmapFactory.decodeStream(contactPhoto.openInputStream(context)) + bitmap = BitmapUtil.createScaledBitmap(bitmap, 300, 300) + icon = IconCompat.createWithAdaptiveBitmap(bitmap) + } catch (e: IOException) { + // Do nothing + } + } + if (icon == null) { + icon = IconCompat.createWithResource(context, if (thread.isGroupRecipient) R.mipmap.ic_group_shortcut else R.mipmap.ic_person_shortcut) + } + return icon + } + + override fun onPostExecute(icon: IconCompat?) { + val name = Optional.fromNullable(thread.name) + .or(Optional.fromNullable(thread.profileName)) + .or(thread.toShortString()) + val shortcutInfo = ShortcutInfoCompat.Builder(context, thread.address.serialize() + '-' + System.currentTimeMillis()) + .setShortLabel(name) + .setIcon(icon) + .setIntent(ShortcutLauncherActivity.createIntent(context, thread.address)) + .build() + if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null)) { + Toast.makeText(context, context.resources.getString(R.string.ConversationActivity_added_to_home_screen), Toast.LENGTH_LONG).show() + } + } + }.execute() + } + + private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { + if (thread.isClosedGroupRecipient) { + val group = DatabaseFactory.getGroupDatabase(context).getGroup(thread.address.toGroupString()).orNull() + if (group?.isActive == false) { return } + } + ExpirationDialog.show(context, thread.expireMessages) { expirationTime: Int -> + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(thread, expirationTime) + val message = ExpirationTimerUpdate(expirationTime) + message.recipient = thread.address.serialize() + message.sentTimestamp = System.currentTimeMillis() + val expiringMessageManager = ApplicationContext.getInstance(context).expiringMessageManager + expiringMessageManager.setExpirationTimer(message) + MessageSender.send(message, thread.address) + val activity = context as AppCompatActivity + activity.invalidateOptionsMenu() + } + } + + private fun unblock(context: Context, thread: Recipient) { + if (!thread.isContactRecipient) { return } + val title = R.string.ConversationActivity_unblock_this_contact_question + val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.ConversationActivity_unblock) { _, _ -> + DatabaseFactory.getRecipientDatabase(context) + .setBlocked(thread, false) + }.show() + } + + private fun block(context: Context, thread: Recipient) { + if (!thread.isContactRecipient) { return } + val title = R.string.RecipientPreferenceActivity_block_this_contact_question + val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ -> + DatabaseFactory.getRecipientDatabase(context) + .setBlocked(thread, true) + }.show() + } + + private fun copySessionID(context: Context, thread: Recipient) { + if (!thread.isContactRecipient) { return } + val sessionID = thread.address.toString() + val clip = ClipData.newPlainText("Session ID", sessionID) + val activity = context as AppCompatActivity + val manager = activity.getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + + private fun editClosedGroup(context: Context, thread: Recipient) { + if (!thread.isClosedGroupRecipient) { return } + val intent = Intent(context, EditClosedGroupActivity::class.java) + val groupID: String = thread.address.toGroupString() + intent.putExtra(groupIDKey, groupID) + context.startActivity(intent) + } + + private fun leaveClosedGroup(context: Context, thread: Recipient) { + if (!thread.isClosedGroupRecipient) { return } + val builder = AlertDialog.Builder(context) + builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group)) + builder.setCancelable(true) + val group = DatabaseFactory.getGroupDatabase(context).getGroup(thread.address.toGroupString()).orNull() + val admins = group.admins + val sessionID = TextSecurePreferences.getLocalNumber(context) + val isCurrentUserAdmin = admins.any { it.toString() == sessionID } + val message = if (isCurrentUserAdmin) { + "Because you are the creator of this group it will be deleted for everyone. This cannot be undone." + } else { + context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) + } + builder.setMessage(message) + builder.setPositiveButton(R.string.yes) { _, _ -> + var groupPublicKey: String? + var isClosedGroup: Boolean + try { + groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() + isClosedGroup = DatabaseFactory.getLokiAPIDatabase(context).isClosedGroup(groupPublicKey) + } catch (e: IOException) { + groupPublicKey = null + isClosedGroup = false + } + try { + if (isClosedGroup) { + MessageSender.leave(groupPublicKey!!, true) + } else { + Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + } + } + builder.setNegativeButton(R.string.no, null) + builder.show() + } + + private fun inviteContacts(context: Context, thread: Recipient) { + if (!thread.isOpenGroupRecipient) { return } + val intent = Intent(context, SelectContactsActivity::class.java) + val activity = context as AppCompatActivity + activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS) + } + + private fun unmute(context: Context, thread: Recipient) { + DatabaseFactory.getRecipientDatabase(context).setMuted(thread, 0) + } + + private fun mute(context: Context, thread: Recipient) { + MuteDialog.show(context) { until: Long -> + DatabaseFactory.getRecipientDatabase(context).setMuted(thread, until) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt new file mode 100644 index 0000000000..78e926d041 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.content.res.Resources +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.view_control_message.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.database.model.MessageRecord + +class ControlMessageView : LinearLayout { + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_control_message, this) + layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) + } + // endregion + + // region Updating + fun bind(message: MessageRecord) { + iconImageView.visibility = View.GONE + if (message.isExpirationTimerUpdate) { + iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme)) + iconImageView.visibility = View.VISIBLE + } else if (message.isMediaSavedNotification) { + iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme)) + iconImageView.visibility = View.VISIBLE + } + textView.text = message.getDisplayBody(context) + } + + fun recycle() { + + } + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt new file mode 100644 index 0000000000..c9daca6c7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import kotlinx.android.synthetic.main.view_document.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord + +class DocumentView : LinearLayout { + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_document, this) + } + // endregion + + // region Updating + fun bind(message: MmsMessageRecord, @ColorInt textColor: Int) { + val document = message.slideDeck.documentSlide!! + documentTitleTextView.text = document.fileName.or("Untitled File") + documentTitleTextView.setTextColor(textColor) + documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + } + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt new file mode 100644 index 0000000000..f684152482 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.text.method.LinkMovementMethod +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.core.text.getSpans +import androidx.core.text.toSpannable +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_link_preview.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.conversation.v2.dialogs.OpenURLDialog +import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.ImageSlide + +class LinkPreviewView : LinearLayout { + private val cornerMask by lazy { CornerMask(this) } + private var url: String? = null + lateinit var bodyTextView: TextView + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_link_preview, this) + } + // endregion + + // region Updating + fun bind(message: MmsMessageRecord, glide: GlideRequests, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, searchQuery: String?) { + val linkPreview = message.linkPreviews.first() + url = linkPreview.url + // Thumbnail + if (linkPreview.getThumbnail().isPresent) { + // This internally fetches the thumbnail + thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + thumbnailImageView.loadIndicator.isVisible = false + } + // Title + titleTextView.text = linkPreview.title + val textColorID = if (message.isOutgoing && UiModeUtilities.isDayUiMode(context)) { + R.color.white + } else { + if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.white + } + titleTextView.setTextColor(ResourcesCompat.getColor(resources, textColorID, context.theme)) + // Body + bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) + mainLinkPreviewContainer.addView(bodyTextView) + // Corner radii + val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) + cornerMask.setTopLeftRadius(cornerRadii[0]) + cornerMask.setTopRightRadius(cornerRadii[1]) + cornerMask.setBottomRightRadius(cornerRadii[2]) + cornerMask.setBottomLeftRadius(cornerRadii[3]) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) + } + // endregion + + // region Interaction + fun calculateHit(event: MotionEvent) { + val rawXInt = event.rawX.toInt() + val rawYInt = event.rawY.toInt() + val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) + val previewRect = Rect() + mainLinkPreviewParent.getGlobalVisibleRect(previewRect) + if (previewRect.contains(hitRect)) { + openURL() + return + } + // intersectedModalSpans should only be a list of one item + val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect) + hitSpans.forEach { span -> + span.onClick(bodyTextView) + } + } + + fun openURL() { + val url = this.url ?: return + val activity = context as AppCompatActivity + OpenURLDialog(url).show(activity.supportFragmentManager, "Open URL Dialog") + } + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt new file mode 100644 index 0000000000..ebcdcac2dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/OpenGroupInvitationView.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.synthetic.main.view_open_group_invitation.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.open_groups.OpenGroupV2 +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.OpenGroupUrlParser +import org.thoughtcrime.securesms.conversation.v2.dialogs.JoinOpenGroupDialog +import org.thoughtcrime.securesms.database.model.MessageRecord + +class OpenGroupInvitationView : LinearLayout { + private var data: UpdateMessageData.Kind.OpenGroupInvitation? = null + + constructor(context: Context): super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet?): super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_open_group_invitation, this) + } + + fun bind(message: MessageRecord, @ColorInt textColor: Int) { + // FIXME: This is a really weird approach... + val umd = UpdateMessageData.fromJSON(message.body)!! + val data = umd.kind as UpdateMessageData.Kind.OpenGroupInvitation + this.data = data + val iconID = if (message.isOutgoing) R.drawable.ic_globe else R.drawable.ic_plus + openGroupInvitationIconImageView.setImageResource(iconID) + openGroupTitleTextView.text = data.groupName + openGroupURLTextView.text = OpenGroupUrlParser.trimQueryParameter(data.groupUrl) + openGroupTitleTextView.setTextColor(textColor) + openGroupJoinMessageTextView.setTextColor(textColor) + openGroupURLTextView.setTextColor(textColor) + } + + fun joinOpenGroup() { + val data = data ?: return + val activity = context as AppCompatActivity + JoinOpenGroupDialog(data.groupName, data.groupUrl).show(activity.supportFragmentManager, "Join Open Group Dialog") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt new file mode 100644 index 0000000000..2ab119950a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.ContentResolver +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Resources +import android.util.AttributeSet +import android.util.Log +import android.view.LayoutInflater +import android.widget.LinearLayout +import android.widget.RelativeLayout +import androidx.annotation.ColorInt +import androidx.core.content.res.ResourcesCompat +import androidx.core.text.toSpannable +import androidx.core.view.isVisible +import androidx.core.view.marginStart +import com.google.android.exoplayer2.util.MimeTypes +import kotlinx.android.synthetic.main.view_link_preview.view.* +import kotlinx.android.synthetic.main.view_quote.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.utilities.UpdateMessageData +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.loki.utilities.* +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.util.MediaUtil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +// There's quite some calculation going on here. It's a bit complex so don't make changes +// if you don't need to. If you do then test: +// • Quoted text in both private chats and group chats +// • Quoted images and videos in both private chats and group chats +// • Quoted voice messages and documents in both private chats and group chats +// • All of the above in both dark mode and light mode + +class QuoteView : LinearLayout { + private lateinit var mode: Mode + private val vPadding by lazy { toPx(6, resources) } + var delegate: QuoteViewDelegate? = null + + enum class Mode { Regular, Draft } + + // region Lifecycle + constructor(context: Context) : super(context) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { throw IllegalAccessError("Use QuoteView(context:mode:) instead.") } + + constructor(context: Context, mode: Mode) : super(context) { + this.mode = mode + LayoutInflater.from(context).inflate(R.layout.view_quote, this) + // Add padding here (not on mainQuoteViewContainer) to get a bit of a top inset while avoiding + // the clipping issue described in getIntrinsicHeight(maxContentWidth:). + setPadding(0, toPx(6, resources), 0, 0) + when (mode) { + Mode.Draft -> quoteViewCancelButton.setOnClickListener { delegate?.cancelQuoteDraft() } + Mode.Regular -> { + quoteViewCancelButton.isVisible = false + mainQuoteViewContainer.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.transparent, context.theme)) + val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams + // Since we're not showing the cancel button we can shorten the end margin + quoteViewMainContentContainerLayoutParams.marginEnd = resources.getDimension(R.dimen.medium_spacing).roundToInt() + quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams + } + } + } + // endregion + + // region General + fun getIntrinsicContentHeight(maxContentWidth: Int): Int { + // If we're showing an attachment thumbnail, just constrain to the height of that + if (quoteViewAttachmentPreviewContainer.isVisible) { return toPx(40, resources) } + var result = 0 + var authorTextViewIntrinsicHeight = 0 + if (quoteViewAuthorTextView.isVisible) { + val author = quoteViewAuthorTextView.text + authorTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(author, quoteViewAuthorTextView.paint, maxContentWidth) + result += authorTextViewIntrinsicHeight + } + val body = quoteViewBodyTextView.text + val bodyTextViewIntrinsicHeight = TextUtilities.getIntrinsicHeight(body, quoteViewBodyTextView.paint, maxContentWidth) + result += bodyTextViewIntrinsicHeight + if (!quoteViewAuthorTextView.isVisible) { + // We want to at least be as high as the cancel button, and no higher than 56 DP (that's + // approximately the height of 3 lines. + return min(max(result, toPx(32, resources)), toPx(56, resources)) + } else { + // Because we're showing the author text view, we should have a height of at least 32 DP + // anyway, so there's no need to constrain to that. We constrain to a max height of 56 DP + // because that's approximately the height of the author text view + 2 lines of the body + // text view. + return min(result, toPx(56, resources)) + } + } + + fun getIntrinsicHeight(maxContentWidth: Int): Int { + // The way all this works is that we just calculate the total height the quote view should be + // and then center everything inside vertically. This effectively means we're applying padding. + // Applying padding the regular way results in a clipping issue though due to a bug in + // RelativeLayout. + return getIntrinsicContentHeight(maxContentWidth) + 2 * vPadding + } + // endregion + + // region Updating + fun bind(authorPublicKey: String, body: String?, attachments: SlideDeck?, thread: Recipient, + isOutgoingMessage: Boolean, maxContentWidth: Int, isOpenGroupInvitation: Boolean, threadID: Long, glide: GlideRequests) { + val contactDB = DatabaseFactory.getSessionContactDatabase(context) + // Reduce the max body text view line count to 2 if this is a group thread because + // we'll be showing the author text view and we don't want the overall quote view height + // to get too big. + quoteViewBodyTextView.maxLines = if (thread.isGroupRecipient) 2 else 3 + // Author + if (thread.isGroupRecipient) { + val author = contactDB.getContactWithSessionID(authorPublicKey) + val authorDisplayName = author?.displayName(Contact.contextForRecipient(thread)) ?: authorPublicKey + quoteViewAuthorTextView.text = authorDisplayName + quoteViewAuthorTextView.setTextColor(getTextColor(isOutgoingMessage)) + } + quoteViewAuthorTextView.isVisible = thread.isGroupRecipient + // Body + quoteViewBodyTextView.text = if (isOpenGroupInvitation) resources.getString(R.string.open_group_invitation_view__open_group_invitation) else MentionUtilities.highlightMentions((body ?: "").toSpannable(), threadID, context); + quoteViewBodyTextView.setTextColor(getTextColor(isOutgoingMessage)) + // Accent line / attachment preview + val hasAttachments = (attachments != null && attachments.asAttachments().isNotEmpty()) + quoteViewAccentLine.isVisible = !hasAttachments + quoteViewAttachmentPreviewContainer.isVisible = hasAttachments + if (!hasAttachments) { + val accentLineLayoutParams = quoteViewAccentLine.layoutParams as RelativeLayout.LayoutParams + accentLineLayoutParams.height = getIntrinsicContentHeight(maxContentWidth) // Match the intrinsic * content * height + quoteViewAccentLine.layoutParams = accentLineLayoutParams + quoteViewAccentLine.setBackgroundColor(getLineColor(isOutgoingMessage)) + } else { + attachments!! + quoteViewAttachmentPreviewImageView.imageTintList = ColorStateList.valueOf(ResourcesCompat.getColor(resources, R.color.white, context.theme)) + val backgroundColorID = if (UiModeUtilities.isDayUiMode(context)) R.color.black else R.color.accent + val backgroundColor = ResourcesCompat.getColor(resources, backgroundColorID, context.theme) + quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) + quoteViewAttachmentPreviewImageView.isVisible = false + quoteViewAttachmentThumbnailImageView.isVisible = false + if (attachments.audioSlide != null) { + quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) + quoteViewAttachmentPreviewImageView.isVisible = true + quoteViewBodyTextView.text = resources.getString(R.string.Slide_audio) + } else if (attachments.documentSlide != null) { + quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_document_large_light) + quoteViewAttachmentPreviewImageView.isVisible = true + quoteViewBodyTextView.text = resources.getString(R.string.document) + } else if (attachments.thumbnailSlide != null) { + val slide = attachments.thumbnailSlide!! + // This internally fetches the thumbnail + quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) + quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) + quoteViewAttachmentThumbnailImageView.isVisible = true + quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) + } + } + mainQuoteViewContainer.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, getIntrinsicHeight(maxContentWidth)) + val quoteViewMainContentContainerLayoutParams = quoteViewMainContentContainer.layoutParams as RelativeLayout.LayoutParams + // The start margin is different if we just show the accent line vs if we show an attachment thumbnail + quoteViewMainContentContainerLayoutParams.marginStart = if (!hasAttachments) toPx(16, resources) else toPx(48, resources) + quoteViewMainContentContainer.layoutParams = quoteViewMainContentContainerLayoutParams + } + // endregion + + // region Convenience + @ColorInt private fun getLineColor(isOutgoingMessage: Boolean): Int { + val isLightMode = UiModeUtilities.isDayUiMode(context) + if ((mode == Mode.Regular && isLightMode) || (mode == Mode.Draft && isLightMode)) { + return ResourcesCompat.getColor(resources, R.color.black, context.theme) + } else if (mode == Mode.Regular && !isLightMode) { + if (isOutgoingMessage) { + return ResourcesCompat.getColor(resources, R.color.black, context.theme) + } else { + return ResourcesCompat.getColor(resources, R.color.accent, context.theme) + } + } else { // Draft & dark mode + return ResourcesCompat.getColor(resources, R.color.accent, context.theme) + } + } + + @ColorInt private fun getTextColor(isOutgoingMessage: Boolean): Int { + if (mode == Mode.Draft) { return ResourcesCompat.getColor(resources, R.color.text, context.theme) } + val isLightMode = UiModeUtilities.isDayUiMode(context) + if ((isOutgoingMessage && !isLightMode) || (!isOutgoingMessage && isLightMode)) { + return ResourcesCompat.getColor(resources, R.color.black, context.theme) + } else { + return ResourcesCompat.getColor(resources, R.color.white, context.theme) + } + } + // endregion +} + +interface QuoteViewDelegate { + + fun cancelQuoteDraft() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt new file mode 100644 index 0000000000..16721b1625 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -0,0 +1,218 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.method.LinkMovementMethod +import android.text.style.URLSpan +import android.text.util.Linkify +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.MotionEvent +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import androidx.core.text.getSpans +import androidx.core.text.toSpannable +import androidx.core.text.util.LinkifyCompat +import kotlinx.android.synthetic.main.view_link_preview.view.* +import kotlinx.android.synthetic.main.view_visible_message_content.view.* +import network.loki.messenger.R +import org.session.libsession.utilities.ThemeUtil +import org.session.libsession.utilities.ViewUtil +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.conversation.v2.dialogs.OpenURLDialog +import org.thoughtcrime.securesms.conversation.v2.utilities.ModalURLSpan +import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getIntersectedModalSpans +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.loki.utilities.* +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.SearchUtil +import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory +import java.util.* +import kotlin.math.roundToInt + +class VisibleMessageContentView : LinearLayout { + var onContentClick: ((event: MotionEvent) -> Unit)? = null + var onContentDoubleTap: (() -> Unit)? = null + var delegate: VisibleMessageContentViewDelegate? = null + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_visible_message_content, this) + } + // endregion + + // region Updating + fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, + glide: GlideRequests, maxWidth: Int, thread: Recipient, searchQuery: String?) { + // Background + val background = getBackground(message.isOutgoing, isStartOfMessageCluster, isEndOfMessageCluster) + val colorID = if (message.isOutgoing) R.attr.message_sent_background_color else R.attr.message_received_background_color + val color = ThemeUtil.getThemedColor(context, colorID) + val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) + background.colorFilter = filter + setBackground(background) + // Body + mainContainer.removeAllViews() + onContentClick = null + onContentDoubleTap = null + if (message is MmsMessageRecord && message.linkPreviews.isNotEmpty()) { + val linkPreviewView = LinkPreviewView(context) + linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster, searchQuery) + mainContainer.addView(linkPreviewView) + onContentClick = { event -> linkPreviewView.calculateHit(event) } + // Body text view is inside the link preview for layout convenience + } else if (message is MmsMessageRecord && message.quote != null) { + val quote = message.quote!! + val quoteView = QuoteView(context, QuoteView.Mode.Regular) + // The max content width is the max message bubble size - 2 times the horizontal padding - the + // quote view content area's start margin. This unfortunately has to be calculated manually + // here to get the layout right. + val maxContentWidth = (maxWidth - 2 * resources.getDimension(R.dimen.medium_spacing) - toPx(16, resources)).roundToInt() + quoteView.bind(quote.author.toString(), quote.text, quote.attachment, thread, + message.isOutgoing, maxContentWidth, message.isOpenGroupInvitation, message.threadId, glide) + mainContainer.addView(quoteView) + val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) + ViewUtil.setPaddingTop(bodyTextView, 0) + mainContainer.addView(bodyTextView) + onContentClick = { event -> + val r = Rect() + quoteView.getGlobalVisibleRect(r) + if (r.contains(event.rawX.roundToInt(), event.rawY.roundToInt())) { + delegate?.scrollToMessageIfPossible(quote.id) + } + } + } else if (message is MmsMessageRecord && message.slideDeck.audioSlide != null) { + val voiceMessageView = VoiceMessageView(context) + voiceMessageView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster) + mainContainer.addView(voiceMessageView) + // We have to use onContentClick (rather than a click listener directly on the voice + // message view) so as to not interfere with all the other gestures. + onContentClick = { voiceMessageView.togglePlayback() } + onContentDoubleTap = { voiceMessageView.handleDoubleTap() } + } else if (message is MmsMessageRecord && message.slideDeck.documentSlide != null) { + val documentView = DocumentView(context) + documentView.bind(message, VisibleMessageContentView.getTextColor(context, message)) + mainContainer.addView(documentView) + } else if (message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty()) { + val albumThumbnailView = AlbumThumbnailView(context) + mainContainer.addView(albumThumbnailView) + // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups + // bind after add view because views are inflated and calculated during bind + albumThumbnailView.bind( + glideRequests = glide, + message = message, + isStart = isStartOfMessageCluster, + isEnd = isEndOfMessageCluster + ) + onContentClick = { event -> + albumThumbnailView.calculateHitObject(event, message, thread) + } + } else if (message.isOpenGroupInvitation) { + val openGroupInvitationView = OpenGroupInvitationView(context) + openGroupInvitationView.bind(message, VisibleMessageContentView.getTextColor(context, message)) + mainContainer.addView(openGroupInvitationView) + onContentClick = { openGroupInvitationView.joinOpenGroup() } + } else { + val bodyTextView = VisibleMessageContentView.getBodyTextView(context, message, searchQuery) + mainContainer.addView(bodyTextView) + onContentClick = { event -> + // intersectedModalSpans should only be a list of one item + bodyTextView.getIntersectedModalSpans(event).forEach { span -> + span.onClick(bodyTextView) + } + } + } + } + + private fun getBackground(isOutgoing: Boolean, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean): Drawable { + val isSingleMessage = (isStartOfMessageCluster && isEndOfMessageCluster) + @DrawableRes val backgroundID: Int + if (isSingleMessage) { + backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone + } else if (isStartOfMessageCluster) { + backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_start else R.drawable.message_bubble_background_received_start + } else if (isEndOfMessageCluster) { + backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_end else R.drawable.message_bubble_background_received_end + } else { + backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_middle else R.drawable.message_bubble_background_received_middle + } + return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!! + } + + fun recycle() { + mainContainer.removeAllViews() + } + // endregion + + // region Convenience + companion object { + + fun getBodyTextView(context: Context, message: MessageRecord, searchQuery: String?): TextView { + val result = EmojiTextView(context) + val vPadding = context.resources.getDimension(R.dimen.small_spacing).toInt() + val hPadding = toPx(12, context.resources) + result.setPadding(hPadding, vPadding, hPadding, vPadding) + result.setTextSize(TypedValue.COMPLEX_UNIT_PX, context.resources.getDimension(R.dimen.small_font_size)) + val color = getTextColor(context, message) + result.setTextColor(color) + result.setLinkTextColor(color) + var body = message.body.toSpannable() + Linkify.addLinks(body, Linkify.WEB_URLS) + + // replace URLSpans with ModalURLSpans + body.getSpans(0, body.length).toList().forEach { urlSpan -> + val replacementSpan = ModalURLSpan(urlSpan.url) { url -> + val activity = context as AppCompatActivity + OpenURLDialog(url).show(activity.supportFragmentManager, "Open URL Dialog") + } + val start = body.getSpanStart(urlSpan) + val end = body.getSpanEnd(urlSpan) + val flags = body.getSpanFlags(urlSpan) + body.removeSpan(urlSpan) + body.setSpan(replacementSpan, start, end, flags) + } + + body = MentionUtilities.highlightMentions(body, message.isOutgoing, message.threadId, context) + body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { BackgroundColorSpan(Color.WHITE) }, body, searchQuery) + body = SearchUtil.getHighlightedSpan(Locale.getDefault(), StyleFactory { ForegroundColorSpan(Color.BLACK) }, body, searchQuery) + + result.text = body + return result + } + + @ColorInt + fun getTextColor(context: Context, message: MessageRecord): Int { + val isDayUiMode = UiModeUtilities.isDayUiMode(context) + val colorID = if (message.isOutgoing) { + if (isDayUiMode) R.color.white else R.color.black + } else { + if (isDayUiMode) R.color.black else R.color.white + } + return context.resources.getColorWithID(colorID, context.theme) + } + } + // endregion +} + +interface VisibleMessageContentViewDelegate { + + fun scrollToMessageIfPossible(timestamp: Long) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt new file mode 100644 index 0000000000..ad90bd5328 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -0,0 +1,367 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.* +import android.widget.LinearLayout +import android.widget.RelativeLayout +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_visible_message.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.contacts.Contact.ContactContext +import org.session.libsession.messaging.open_groups.OpenGroupAPIV2 +import org.session.libsession.utilities.ViewUtil +import org.session.libsignal.utilities.ThreadUtils +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.loki.utilities.disableClipping +import org.thoughtcrime.securesms.loki.utilities.getColorWithID +import org.thoughtcrime.securesms.loki.utilities.toDp +import org.thoughtcrime.securesms.loki.utilities.toPx +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.DateUtils +import java.util.* +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +class VisibleMessageView : LinearLayout { + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels + private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() + private val swipeToReplyIconRect = Rect() + private var dx = 0.0f + private var previousTranslationX = 0.0f + private val gestureHandler = Handler(Looper.getMainLooper()) + private var pressCallback: Runnable? = null + private var longPressCallback: Runnable? = null + private var onDownTimestamp = 0L + private var onDoubleTap: (() -> Unit)? = null + var snIsSelected = false + set(value) { field = value; handleIsSelectedChanged()} + var onPress: ((event: MotionEvent) -> Unit)? = null + var onSwipeToReply: (() -> Unit)? = null + var onLongPress: (() -> Unit)? = null + var contentViewDelegate: VisibleMessageContentViewDelegate? = null + + companion object { + const val swipeToReplyThreshold = 80.0f // dp + const val longPressMovementTreshold = 10.0f // dp + const val longPressDurationThreshold = 250L // ms + const val maxDoubleTapInterval = 200L + } + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_visible_message, this) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + isHapticFeedbackEnabled = true + setWillNotDraw(false) + expirationTimerViewContainer.disableClipping() + messageContentContainer.disableClipping() + } + // endregion + + // region Updating + fun bind(message: MessageRecord, previous: MessageRecord?, next: MessageRecord?, glide: GlideRequests, searchQuery: String?) { + val sender = message.individualRecipient + val senderSessionID = sender.address.serialize() + val threadID = message.threadId + val threadDB = DatabaseFactory.getThreadDatabase(context) + val thread = threadDB.getRecipientForThreadId(threadID)!! + val contactDB = DatabaseFactory.getSessionContactDatabase(context) + val isGroupThread = thread.isGroupRecipient + val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) + val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) + // Show profile picture and sender name if this is a group thread AND + // the message is incoming + if (isGroupThread && !message.isOutgoing) { + profilePictureContainer.visibility = if (isEndOfMessageCluster) View.VISIBLE else View.INVISIBLE + profilePictureView.publicKey = senderSessionID + profilePictureView.glide = glide + profilePictureView.update() + if (thread.isOpenGroupRecipient) { + val openGroup = DatabaseFactory.getLokiThreadDatabase(context).getOpenGroupChat(threadID)!! + val isModerator = OpenGroupAPIV2.isUserModerator(senderSessionID, openGroup.room, openGroup.server) + moderatorIconImageView.visibility = if (isModerator) View.VISIBLE else View.INVISIBLE + } else { + moderatorIconImageView.visibility = View.INVISIBLE + } + senderNameTextView.isVisible = isStartOfMessageCluster + val context = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + senderNameTextView.text = contactDB.getContactWithSessionID(senderSessionID)?.displayName(context) ?: senderSessionID + } else { + profilePictureContainer.visibility = View.GONE + senderNameTextView.visibility = View.GONE + } + // Date break + val showDateBreak = (previous == null || !DateUtils.isSameDay(message.timestamp, previous.timestamp)) + dateBreakTextView.isVisible = showDateBreak + dateBreakTextView.text = if (showDateBreak) DateUtils.getRelativeDate(context, Locale.getDefault(), message.timestamp) else "" + // Timestamp + messageTimestampTextView.text = DateUtils.getExtendedRelativeTimeSpanString(context, Locale.getDefault(), message.timestamp) + // Margins + val startPadding: Int + if (isGroupThread) { + startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt() else 0 + } else { + startPadding = if (message.isOutgoing) resources.getDimension(R.dimen.very_large_spacing).toInt() + else resources.getDimension(R.dimen.medium_spacing).toInt() + } + val endPadding = if (message.isOutgoing) resources.getDimension(R.dimen.medium_spacing).toInt() + else resources.getDimension(R.dimen.very_large_spacing).toInt() + messageContentContainer.setPaddingRelative(startPadding, 0, endPadding, 0) + // Set inter-message spacing + setMessageSpacing(isStartOfMessageCluster, isEndOfMessageCluster) + // Gravity + val gravity = if (message.isOutgoing) Gravity.END else Gravity.START + mainContainer.gravity = gravity or Gravity.BOTTOM + // Message status indicator + val (iconID, iconColor) = getMessageStatusImage(message) + if (iconID != null) { + val drawable = ContextCompat.getDrawable(context, iconID)?.mutate() + if (iconColor != null) { + drawable?.setTint(iconColor) + } + messageStatusImageView.setImageDrawable(drawable) + } + if (message.isOutgoing) { + val lastMessageID = DatabaseFactory.getMmsSmsDatabase(context).getLastMessageID(message.threadId) + messageStatusImageView.isVisible = !message.isSent || message.id == lastMessageID + } else { + messageStatusImageView.isVisible = false + } + // Expiration timer + updateExpirationTimer(message) + // Calculate max message bubble width + var maxWidth = screenWidth - startPadding - endPadding + if (profilePictureContainer.visibility != View.GONE) { maxWidth -= profilePictureContainer.width } + // Populate content view + messageContentView.bind(message, isStartOfMessageCluster, isEndOfMessageCluster, glide, maxWidth, thread, searchQuery) + messageContentView.delegate = contentViewDelegate + onDoubleTap = { messageContentView.onContentDoubleTap?.invoke() } + } + + private fun setMessageSpacing(isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { + val topPadding = if (isStartOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse + ViewUtil.setPaddingTop(this, resources.getDimension(topPadding).roundToInt()) + val bottomPadding = if (isEndOfMessageCluster) R.dimen.conversation_vertical_message_spacing_default else R.dimen.conversation_vertical_message_spacing_collapse + ViewUtil.setPaddingBottom(this, resources.getDimension(bottomPadding).roundToInt()) + } + + private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { + return if (isGroupThread) { + previous == null || previous.isUpdate || !DateUtils.isSameDay(current.timestamp, previous.timestamp) + || current.recipient.address != previous.recipient.address + } else { + previous == null || previous.isUpdate || !DateUtils.isSameDay(current.timestamp, previous.timestamp) + || current.isOutgoing != previous.isOutgoing + } + } + + private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { + return if (isGroupThread) { + next == null || next.isUpdate || !DateUtils.isSameDay(current.timestamp, next.timestamp) + || current.recipient.address != next.recipient.address + } else { + next == null || next.isUpdate || !DateUtils.isSameDay(current.timestamp, next.timestamp) + || current.isOutgoing != next.isOutgoing + } + } + + private fun getMessageStatusImage(message: MessageRecord): Pair { + return when { + !message.isOutgoing -> null to null + message.isFailed -> R.drawable.ic_error to resources.getColor(R.color.destructive, context.theme) + message.isPending -> R.drawable.ic_circle_dot_dot_dot to null + message.isRead -> R.drawable.ic_filled_circle_check to null + else -> R.drawable.ic_circle_check to null + } + } + + private fun updateExpirationTimer(message: MessageRecord) { + val expirationTimerViewLayoutParams = expirationTimerView.layoutParams as RelativeLayout.LayoutParams + val ruleToAdd = if (message.isOutgoing) RelativeLayout.ALIGN_START else RelativeLayout.ALIGN_END + val ruleToRemove = if (message.isOutgoing) RelativeLayout.ALIGN_END else RelativeLayout.ALIGN_START + expirationTimerViewLayoutParams.removeRule(ruleToRemove) + expirationTimerViewLayoutParams.addRule(ruleToAdd, R.id.messageContentView) + val expirationTimerViewSize = toPx(12, resources) + val smallSpacing = resources.getDimension(R.dimen.small_spacing).roundToInt() + expirationTimerViewLayoutParams.marginStart = if (message.isOutgoing) -(smallSpacing + expirationTimerViewSize) else 0 + expirationTimerViewLayoutParams.marginEnd = if (message.isOutgoing) 0 else -(smallSpacing + expirationTimerViewSize) + expirationTimerView.layoutParams = expirationTimerViewLayoutParams + if (message.expiresIn > 0 && !message.isPending) { + expirationTimerView.setColorFilter(ResourcesCompat.getColor(resources, R.color.text, context.theme)) + expirationTimerView.isVisible = true + expirationTimerView.setPercentComplete(0.0f) + if (message.expireStarted > 0) { + expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + expirationTimerView.startAnimation() + if (message.expireStarted + message.expiresIn <= System.currentTimeMillis()) { + ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule() + } + } else if (!message.isOutgoing && !message.isMediaPending) { + ThreadUtils.queue { + val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager + val id = message.getId() + val mms = message.isMms + if (mms) DatabaseFactory.getMmsDatabase(context).markExpireStarted(id) else DatabaseFactory.getSmsDatabase(context).markExpireStarted(id) + expirationManager.scheduleDeletion(id, mms, message.expiresIn) + } + } + } else { + expirationTimerView.isVisible = false + } + } + + private fun handleIsSelectedChanged() { + background = if (snIsSelected) { + ColorDrawable(context.resources.getColorWithID(R.color.message_selected, context.theme)) + } else { + null + } + } + + override fun onDraw(canvas: Canvas) { + if (translationX < 0 && !expirationTimerView.isVisible) { + val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) + val threshold = VisibleMessageView.swipeToReplyThreshold + val iconSize = toPx(24, context.resources) + val bottomVOffset = paddingBottom + messageStatusImageView.height + (messageContentView.height - iconSize) / 2 + swipeToReplyIconRect.left = messageContentContainer.right - messageContentContainer.paddingEnd + spacing + swipeToReplyIconRect.top = height - bottomVOffset - iconSize + swipeToReplyIconRect.right = messageContentContainer.right - messageContentContainer.paddingEnd + iconSize + spacing + swipeToReplyIconRect.bottom = height - bottomVOffset + swipeToReplyIcon.bounds = swipeToReplyIconRect + swipeToReplyIcon.alpha = (255.0f * (min(abs(translationX), threshold) / threshold)).roundToInt() + } else { + swipeToReplyIcon.alpha = 0 + } + swipeToReplyIcon.draw(canvas) + super.onDraw(canvas) + } + + fun recycle() { + profilePictureView.recycle() + messageContentView.recycle() + } + // endregion + + // region Interaction + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> onDown(event) + MotionEvent.ACTION_MOVE -> onMove(event) + MotionEvent.ACTION_CANCEL -> onCancel(event) + MotionEvent.ACTION_UP -> onUp(event) + } + return true + } + + private fun onDown(event: MotionEvent) { + dx = x - event.rawX + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + val newLongPressCallback = Runnable { onLongPress() } + this.longPressCallback = newLongPressCallback + gestureHandler.postDelayed(newLongPressCallback, VisibleMessageView.longPressDurationThreshold) + onDownTimestamp = Date().time + } + + private fun onMove(event: MotionEvent) { + val translationX = toDp(event.rawX + dx, context.resources) + if (abs(translationX) < VisibleMessageView.longPressMovementTreshold || snIsSelected) { + return + } else { + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + } + if (translationX > 0) { return } // Only allow swipes to the left + // The idea here is to asymptotically approach a maximum drag distance + val damping = 50.0f + val sign = -1.0f + val x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign + this.translationX = x + this.dateBreakTextView.translationX = -x // Bit of a hack to keep the date break text view from moving + postInvalidate() // Ensure onDraw(canvas:) is called + if (abs(x) > VisibleMessageView.swipeToReplyThreshold && abs(previousTranslationX) < VisibleMessageView.swipeToReplyThreshold) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + } else { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + } + previousTranslationX = x + } + + private fun onCancel(event: MotionEvent) { + if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) { + onSwipeToReply?.invoke() + } + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + resetPosition() + } + + private fun onUp(event: MotionEvent) { + if (abs(translationX) > VisibleMessageView.swipeToReplyThreshold) { + onSwipeToReply?.invoke() + } else if ((Date().time - onDownTimestamp) < VisibleMessageView.longPressDurationThreshold) { + longPressCallback?.let { gestureHandler.removeCallbacks(it) } + val pressCallback = this.pressCallback + if (pressCallback != null) { + // If we're here and pressCallback isn't null, it means that we tapped again within + // maxDoubleTapInterval ms and we should count this as a double tap + gestureHandler.removeCallbacks(pressCallback) + this.pressCallback = null + onDoubleTap?.invoke() + } else { + val newPressCallback = Runnable { onPress(event) } + this.pressCallback = newPressCallback + gestureHandler.postDelayed(newPressCallback, VisibleMessageView.maxDoubleTapInterval) + } + } + resetPosition() + } + + private fun resetPosition() { + animate() + .translationX(0.0f) + .setDuration(150) + .setUpdateListener { + postInvalidate() // Ensure onDraw(canvas:) is called + } + .start() + // Bit of a hack to keep the date break text view from moving + dateBreakTextView.animate() + .translationX(0.0f) + .setDuration(150) + .start() + } + + private fun onLongPress() { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onLongPress?.invoke() + } + + fun onContentClick(event: MotionEvent) { + messageContentView.onContentClick?.invoke(event) + } + + private fun onPress(event: MotionEvent) { + onPress?.invoke(event) + pressCallback = null + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt new file mode 100644 index 0000000000..b957b0a166 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.util.Log +import android.view.* +import android.widget.LinearLayout +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.view_voice_message.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.audio.AudioSlidePlayer +import org.thoughtcrime.securesms.components.CornerMask +import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtilities +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +class VoiceMessageView : LinearLayout, AudioSlidePlayer.Listener { + private val cornerMask by lazy { CornerMask(this) } + private var isPlaying = false + private var progress = 0.0 + private var duration = 0L + private var player: AudioSlidePlayer? = null + private var isPreparing = false + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + private fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_voice_message, this) + voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(0), + TimeUnit.MILLISECONDS.toSeconds(0)) + } + // endregion + + // region Updating + fun bind(message: MmsMessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean) { + val audio = message.slideDeck.audioSlide!! + val player = AudioSlidePlayer.createFor(context, audio, this) + this.player = player + isPreparing = true + if (!audio.isPendingDownload && !audio.isInProgress) { + player.play(0.0) + } + voiceMessageViewLoader.isVisible = audio.isPendingDownload + val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) + cornerMask.setTopLeftRadius(cornerRadii[0]) + cornerMask.setTopRightRadius(cornerRadii[1]) + cornerMask.setBottomRightRadius(cornerRadii[2]) + cornerMask.setBottomLeftRadius(cornerRadii[3]) + } + + override fun onPlayerStart(player: AudioSlidePlayer) { + if (!isPreparing) { return } + isPreparing = false + duration = player.duration + voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(duration), + TimeUnit.MILLISECONDS.toSeconds(duration)) + player.stop() + } + + override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, unused: Long) { + if (progress == 1.0) { + togglePlayback() + handleProgressChanged(0.0) + } else { + handleProgressChanged(progress) + } + } + + private fun handleProgressChanged(progress: Double) { + this.progress = progress + voiceMessageViewDurationTextView.text = String.format("%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()), + TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong())) + val layoutParams = progressView.layoutParams as RelativeLayout.LayoutParams + layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt() + progressView.layoutParams = layoutParams + } + + override fun onPlayerStop(player: AudioSlidePlayer) { } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + cornerMask.mask(canvas) + } + // endregion + + // region Interaction + fun togglePlayback() { + val player = this.player ?: return + isPlaying = !isPlaying + val iconID = if (isPlaying) R.drawable.exo_icon_pause else R.drawable.exo_icon_play + voiceMessagePlaybackImageView.setImageResource(iconID) + if (isPlaying) { + player.play(progress) + } else { + player.stop() + } + } + + fun handleDoubleTap() { + val player = this.player ?: return + player.playbackSpeed = if (player.playbackSpeed == 1.0f) 1.5f else 1.0f + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt new file mode 100644 index 0000000000..da8df0045a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchBottomBar.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.conversation.v2.search + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import kotlinx.android.synthetic.main.view_search_bottom_bar.view.* +import network.loki.messenger.R + + +class SearchBottomBar : LinearLayout { + private var eventListener: EventListener? = null + + // region Lifecycle + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + + fun initialize() { + LayoutInflater.from(context).inflate(R.layout.view_search_bottom_bar, this) + } + + fun setData(position: Int, count: Int) { + searchProgressWheel.visibility = GONE + searchUp.setOnClickListener { v: View? -> + if (eventListener != null) { + eventListener!!.onSearchMoveUpPressed() + } + } + searchDown.setOnClickListener { v: View? -> + if (eventListener != null) { + eventListener!!.onSearchMoveDownPressed() + } + } + if (count > 0) { + searchPosition.text = resources.getString(R.string.ConversationActivity_search_position, position + 1, count) + } else { + searchPosition.text = "" + } + setViewEnabled(searchUp, position < count - 1) + setViewEnabled(searchDown, position > 0) + } + + fun showLoading() { + searchProgressWheel.visibility = VISIBLE + } + + private fun setViewEnabled(view: View, enabled: Boolean) { + view.isEnabled = enabled + view.alpha = if (enabled) 1f else 0.25f + } + + fun setEventListener(eventListener: EventListener?) { + this.eventListener = eventListener + } + + interface EventListener { + fun onSearchMoveUpPressed() + fun onSearchMoveDownPressed() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt new file mode 100644 index 0000000000..eb3dd50d98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.conversation.v2.search + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import org.session.libsession.utilities.Debouncer +import org.session.libsession.utilities.Util.runOnMain +import org.session.libsession.utilities.concurrent.SignalExecutors +import org.thoughtcrime.securesms.contacts.ContactAccessor +import org.thoughtcrime.securesms.database.CursorList +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.search.SearchRepository +import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.util.CloseableLiveData +import java.io.Closeable + + +class SearchViewModel(application: Application) : AndroidViewModel(application) { + private val searchRepository: SearchRepository + private val result: CloseableLiveData + private val debouncer: Debouncer + private var firstSearch = false + private var searchOpen = false + private var activeQuery: String? = null + private var activeThreadId: Long = 0 + val searchResults: LiveData + get() = result + + fun onQueryUpdated(query: String, threadId: Long) { + if (query == activeQuery) { + return + } + updateQuery(query, threadId) + } + + fun onMissingResult() { + if (activeQuery != null) { + updateQuery(activeQuery!!, activeThreadId) + } + } + + fun onMoveUp() { + debouncer.clear() + val messages = result.value!!.getResults() as CursorList + val position = Math.min(result.value!!.position + 1, messages.size - 1) + result.setValue(SearchResult(messages, position), false) + } + + fun onMoveDown() { + debouncer.clear() + val messages = result.value!!.getResults() as CursorList + val position = Math.max(result.value!!.position - 1, 0) + result.setValue(SearchResult(messages, position), false) + } + + fun onSearchOpened() { + searchOpen = true + firstSearch = true + } + + fun onSearchClosed() { + searchOpen = false + activeQuery = null + debouncer.clear() + result.close() + } + + override fun onCleared() { + super.onCleared() + result.close() + } + + private fun updateQuery(query: String, threadId: Long) { + activeQuery = query + activeThreadId = threadId + debouncer.publish { + firstSearch = false + searchRepository.query(query, threadId) { messages: CursorList -> + runOnMain { + if (searchOpen && query == activeQuery) { + result.setValue(SearchResult(messages, 0)) + } else { + messages.close() + } + } + } + } + } + + class SearchResult(private val results: CursorList, val position: Int) : Closeable { + + fun getResults(): List { + return results + } + + override fun close() { + results.close() + } + } + + init { + val context = application.applicationContext + result = CloseableLiveData() + debouncer = Debouncer(500) + searchRepository = SearchRepository(context, + DatabaseFactory.getSearchDatabase(context), + DatabaseFactory.getThreadDatabase(context), + ContactAccessor.getInstance(), + SignalExecutors.SERIAL) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java similarity index 58% rename from app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index dcac22909c..a5298305a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.thoughtcrime.securesms.mms; +package org.thoughtcrime.securesms.conversation.v2.utilities; import android.Manifest; import android.annotation.SuppressLint; @@ -23,29 +23,31 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.database.Cursor; -import android.graphics.PorterDuff; import android.net.Uri; import android.os.AsyncTask; -import android.provider.ContactsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; import android.text.TextUtils; import android.util.Pair; -import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.MediaPreviewActivity; -import org.thoughtcrime.securesms.loki.views.MessageAudioView; -import org.thoughtcrime.securesms.components.DocumentView; -import org.thoughtcrime.securesms.components.RemovableEditableMediaView; -import org.thoughtcrime.securesms.components.ThumbnailView; import org.session.libsignal.utilities.NoExternalStorageException; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.GifSlide; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.mms.ImageSlide; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.Slide; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; import org.session.libsignal.utilities.ExternalStorageUtil; @@ -53,13 +55,8 @@ import org.thoughtcrime.securesms.util.FileProviderUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.session.libsignal.utilities.guava.Optional; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.ThemeUtil; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsession.utilities.Stub; import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.ListenableFuture.Listener; import org.session.libsignal.utilities.SettableFuture; import java.io.File; @@ -67,26 +64,18 @@ import java.io.IOException; import java.util.Iterator; import java.util.LinkedList; import java.util.List; -import java.util.concurrent.ExecutionException; import network.loki.messenger.R; import static android.provider.MediaStore.EXTRA_OUTPUT; - public class AttachmentManager { private final static String TAG = AttachmentManager.class.getSimpleName(); private final @NonNull Context context; - private final @NonNull Stub attachmentViewStub; private final @NonNull AttachmentListener attachmentListener; - private RemovableEditableMediaView removableMediaView; - private ThumbnailView thumbnail; - private MessageAudioView audioView; - private DocumentView documentView; - private @NonNull List garbage = new LinkedList<>(); private @NonNull Optional slide = Optional.absent(); private @Nullable Uri captureUri; @@ -94,51 +83,12 @@ public class AttachmentManager { public AttachmentManager(@NonNull Activity activity, @NonNull AttachmentListener listener) { this.context = activity; this.attachmentListener = listener; - this.attachmentViewStub = ViewUtil.findStubById(activity, R.id.attachment_editor_stub); } - private void inflateStub() { - if (!attachmentViewStub.resolved()) { - View root = attachmentViewStub.get(); - - this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); - this.audioView = ViewUtil.findById(root, R.id.attachment_audio); - this.documentView = ViewUtil.findById(root, R.id.attachment_document); - this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view); - - removableMediaView.setRemoveClickListener(new RemoveButtonListener()); - thumbnail.setOnClickListener(new ThumbnailClickListener()); - documentView.getBackground().setColorFilter(ThemeUtil.getThemedColor(context, R.attr.conversation_item_bubble_background), PorterDuff.Mode.MULTIPLY); - } - } - - public void clear(@NonNull GlideRequests glideRequests, boolean animate) { - if (attachmentViewStub.resolved()) { - - if (animate) { - ViewUtil.fadeOut(attachmentViewStub.get(), 200).addListener(new Listener() { - @Override - public void onSuccess(Boolean result) { - thumbnail.clear(glideRequests); - attachmentViewStub.get().setVisibility(View.GONE); - attachmentListener.onAttachmentChanged(); - } - - @Override - public void onFailure(ExecutionException e) { - } - }); - } else { - thumbnail.clear(glideRequests); - attachmentViewStub.get().setVisibility(View.GONE); - attachmentListener.onAttachmentChanged(); - } - - markGarbage(getSlideUri()); - slide = Optional.absent(); - - audioView.cleanup(); - } + public void clear() { + markGarbage(getSlideUri()); + slide = Optional.absent(); + attachmentListener.onAttachmentChanged(); } public void cleanup() { @@ -190,16 +140,12 @@ public class AttachmentManager { final int width, final int height) { - inflateStub(); - final SettableFuture result = new SettableFuture<>(); new AsyncTask() { @Override protected void onPreExecute() { - thumbnail.clear(glideRequests); - thumbnail.showProgressSpinner(); - attachmentViewStub.get().setVisibility(View.VISIBLE); + } @Override @@ -222,35 +168,12 @@ public class AttachmentManager { @Override protected void onPostExecute(@Nullable final Slide slide) { if (slide == null) { - attachmentViewStub.get().setVisibility(View.GONE); - Toast.makeText(context, - R.string.ConversationActivity_sorry_there_was_an_error_setting_your_attachment, - Toast.LENGTH_SHORT).show(); result.set(false); } else if (!areConstraintsSatisfied(context, slide, constraints)) { - attachmentViewStub.get().setVisibility(View.GONE); - Toast.makeText(context, - R.string.ConversationActivity_attachment_exceeds_size_limits, - Toast.LENGTH_SHORT).show(); result.set(false); } else { setSlide(slide); - attachmentViewStub.get().setVisibility(View.VISIBLE); - - if (slide.hasAudio()) { - audioView.setAudio((AudioSlide) slide, false); - removableMediaView.display(audioView, false); - result.set(true); - } else if (slide.hasDocument()) { - documentView.setDocument((DocumentSlide) slide, false); - removableMediaView.display(documentView, false); - result.set(true); - } else { - Attachment attachment = slide.asAttachment(); - result.deferTo(thumbnail.setImageResource(glideRequests, slide, false, true, attachment.getWidth(), attachment.getHeight())); - removableMediaView.display(thumbnail, mediaType == MediaType.IMAGE); - } - + result.set(true); attachmentListener.onAttachmentChanged(); } } @@ -317,11 +240,8 @@ public class AttachmentManager { return result; } - public boolean isAttachmentPresent() { - return attachmentViewStub.resolved() && attachmentViewStub.get().getVisibility() == View.VISIBLE; - } - - public @NonNull SlideDeck buildSlideDeck() { + public @NonNull + SlideDeck buildSlideDeck() { SlideDeck deck = new SlideDeck(); if (slide.isPresent()) deck.addSlide(slide.get()); return deck; @@ -333,43 +253,16 @@ public class AttachmentManager { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { Permissions.with(activity) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) - .execute(); + .request(Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) + .execute(); } public static void selectAudio(Activity activity, int requestCode) { selectMediaType(activity, "audio/*", null, requestCode); } - public static void selectContactInfo(Activity activity, int requestCode) { - Permissions.with(activity) - .request(Manifest.permission.WRITE_CONTACTS) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_contacts_permission_in_order_to_attach_contact_information)) - .onAllGranted(() -> { - Intent intent = new Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI); - activity.startActivityForResult(intent, requestCode); - }) - .execute(); - } - - public static void selectLocation(Activity activity, int requestCode) { - /* Loki - Enable again once we have location sharing - Permissions.with(activity) - .request(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_location_information_in_order_to_attach_a_location)) - .onAllGranted(() -> { - try { - activity.startActivityForResult(new PlacePicker.IntentBuilder().build(activity), requestCode); - } catch (GooglePlayServicesRepairableException | GooglePlayServicesNotAvailableException e) { - Log.w(TAG, e); - } - }) - .execute(); - */ - } - public static void selectGif(Activity activity, int requestCode) { Intent intent = new Intent(activity, GiphyActivity.class); intent.putExtra(GiphyActivity.EXTRA_IS_MMS, false); @@ -386,28 +279,25 @@ public class AttachmentManager { public void capturePhoto(Activity activity, int requestCode) { Permissions.with(activity) - .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) - .onAllGranted(() -> { - try { - File captureFile = File.createTempFile( - "conversation-capture", - ".jpg", - ExternalStorageUtil.getImageDir(activity)); - Uri captureUri = FileProviderUtil.getUriFor(context, captureFile); - Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - captureIntent.putExtra(EXTRA_OUTPUT, captureUri); - captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { - Log.d(TAG, "captureUri path is " + captureUri.getPath()); - this.captureUri = captureUri; - activity.startActivityForResult(captureIntent, requestCode); - } - } catch (IOException | NoExternalStorageException e) { - throw new RuntimeException("Error creating image capture intent.", e); - } - }) - .execute(); + .request(Manifest.permission.CAMERA) + .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied)) + .onAllGranted(() -> { + try { + File captureFile = File.createTempFile("conversation-capture", ".jpg", ExternalStorageUtil.getImageDir(activity)); + Uri captureUri = FileProviderUtil.getUriFor(context, captureFile); + Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + captureIntent.putExtra(EXTRA_OUTPUT, captureUri); + captureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (captureIntent.resolveActivity(activity.getPackageManager()) != null) { + Log.d(TAG, "captureUri path is " + captureUri.getPath()); + this.captureUri = captureUri; + activity.startActivityForResult(captureIntent, requestCode); + } + } catch (IOException | NoExternalStorageException e) { + throw new RuntimeException("Error creating image capture intent.", e); + } + }) + .execute(); } private static void selectMediaType(Activity activity, @NonNull String type, @Nullable String[] extraMimeType, int requestCode) { @@ -445,34 +335,6 @@ public class AttachmentManager { constraints.canResize(slide.asAttachment()); } - private void previewImageDraft(final @NonNull Slide slide) { - if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { - Intent intent = new Intent(context, MediaPreviewActivity.class); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, slide.asAttachment().getSize()); - intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, slide.getCaption().orNull()); - intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, true); - intent.setDataAndType(slide.getUri(), slide.getContentType()); - - context.startActivity(intent); - } - } - - private class ThumbnailClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - if (slide.isPresent()) previewImageDraft(slide.get()); - } - } - - private class RemoveButtonListener implements View.OnClickListener { - @Override - public void onClick(View v) { - cleanup(); - clear(GlideApp.with(context.getApplicationContext()), true); - } - } - public interface AttachmentListener { void onAttachmentChanged(); } @@ -513,6 +375,5 @@ public class AttachmentManager { return DOCUMENT; } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt new file mode 100644 index 0000000000..79bb1405a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities + +open class BaseDialog : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(requireContext()) + setContentView(builder) + val result = builder.create() + result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) + result.window?.setDimAmount(if (isLightMode) 0.1f else 0.75f) + return result + } + + open fun setContentView(builder: AlertDialog.Builder) { + // To be overridden by subclasses + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt new file mode 100644 index 0000000000..d432ed0f79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.core.view.isVisible +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestOptions +import kotlinx.android.synthetic.main.thumbnail_view.view.* +import network.loki.messenger.R +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.utilities.Util.equals +import org.session.libsignal.utilities.ListenableFuture +import org.session.libsignal.utilities.SettableFuture +import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget +import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.* +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri + +open class KThumbnailView: FrameLayout { + + companion object { + private const val WIDTH = 0 + private const val HEIGHT = 1 + } + + // region Lifecycle + constructor(context: Context) : super(context) { initialize(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } + + private val image by lazy { thumbnail_image } + private val playOverlay by lazy { play_overlay } + val loadIndicator: View by lazy { thumbnail_load_indicator } + + private val dimensDelegate = ThumbnailDimensDelegate() + + private var slide: Slide? = null + private var radius: Int = 0 + + private fun initialize(attrs: AttributeSet?) { + inflate(context, R.layout.thumbnail_view, this) + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) + + dimensDelegate.setBounds(typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0), + typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0), + typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0)) + + radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0) + + typedArray.recycle() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val adjustedDimens = dimensDelegate.resourceSize() + if (adjustedDimens[WIDTH] == 0 && adjustedDimens[HEIGHT] == 0) { + return super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + val finalWidth: Int = adjustedDimens[WIDTH] + paddingLeft + paddingRight + val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom + + super.onMeasure( + MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) + ) + } + + private fun getDefaultWidth() = maxOf(layoutParams?.width ?: 0, 0) + private fun getDefaultHeight() = maxOf(layoutParams?.height ?: 0, 0) + // endregion + + // region Interaction + fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture { + return setImageResource(glide, slide, isPreview, 0, 0, mms) + } + + fun setImageResource(glide: GlideRequests, slide: Slide, + isPreview: Boolean, naturalWidth: Int, + naturalHeight: Int, mms: MmsMessageRecord): ListenableFuture { + + val currentSlide = this.slide + + playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) + + if (equals(currentSlide, slide)) { + // don't re-load slide + return SettableFuture(false) + } + + + if (currentSlide != null && currentSlide.fastPreflightId != null && currentSlide.fastPreflightId == slide.fastPreflightId) { + // not reloading slide for fast preflight + this.slide = slide + } + + this.slide = slide + + loadIndicator.isVisible = slide.isInProgress && !mms.isFailed + + dimensDelegate.setDimens(naturalWidth, naturalHeight) + invalidate() + + val result = SettableFuture() + + when { + slide.thumbnailUri != null -> { + buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result)) + } + slide.hasPlaceholder() -> { + buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result)) + } + else -> { + glide.clear(image) + result.set(false) + } + } + return result + } + + fun buildThumbnailGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + + val dimens = dimensDelegate.resourceSize() + + val request = glide.load(DecryptableUri(slide.thumbnailUri!!)) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .let { request -> + if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { + request.override(getDefaultWidth(), getDefaultHeight()) + } else { + request.override(dimens[WIDTH], dimens[HEIGHT]) + } + } + .transition(DrawableTransitionOptions.withCrossFade()) + .centerCrop() + + return if (slide.isInProgress) request else request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)) + } + + fun buildPlaceholderGlideRequest(glide: GlideRequests, slide: Slide): GlideRequest { + + val dimens = dimensDelegate.resourceSize() + + return glide.asBitmap() + .load(slide.getPlaceholderRes(context.theme)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .let { request -> + if (dimens[WIDTH] == 0 || dimens[HEIGHT] == 0) { + request.override(getDefaultWidth(), getDefaultHeight()) + } else { + request.override(dimens[WIDTH], dimens[HEIGHT]) + } + } + .fitCenter() + } + + open fun clear(glideRequests: GlideRequests) { + glideRequests.clear(image) + slide = null + } + + fun setImageResource(glideRequests: GlideRequests, uri: Uri): ListenableFuture { + val future = SettableFuture() + + var request: GlideRequest = glideRequests.load(DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + + request = if (radius > 0) { + request.transforms(CenterCrop(), RoundedCorners(radius)) + } else { + request.transforms(CenterCrop()) + } + + request.into(GlideDrawableListeningTarget(image, future)) + + return future + } + + // endregion + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt new file mode 100644 index 0000000000..c4c5d5a5d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MessageBubbleUtilities.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.content.Context +import network.loki.messenger.R +import kotlin.math.roundToInt + +object MessageBubbleUtilities { + + fun calculateRadii(context: Context, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, isOutgoing: Boolean): IntArray { + val roundedDimen = context.resources.getDimension(R.dimen.message_corner_radius).roundToInt() + val collapsedDimen = context.resources.getDimension(R.dimen.message_corner_collapse_radius).roundToInt() + val (tl, tr, bl, br) = when { + // Single message + isStartOfMessageCluster && isEndOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, roundedDimen, roundedDimen) + // Start of message cluster; collapsed BL + isStartOfMessageCluster -> intArrayOf(roundedDimen, roundedDimen, collapsedDimen, roundedDimen) + // End of message cluster; collapsed TL + isEndOfMessageCluster -> intArrayOf(collapsedDimen, roundedDimen, roundedDimen, roundedDimen) + // In the middle; no rounding on the left + else -> intArrayOf(collapsedDimen, roundedDimen, collapsedDimen, roundedDimen) + } + // TL, TR, BR, BL (CW direction) + // Flip if the message is outgoing + return intArrayOf( + if (!isOutgoing) tl else tr, // TL + if (!isOutgoing) tr else tl, // TR + if (!isOutgoing) br else bl, // BR + if (!isOutgoing) bl else br // BL + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ModalURLSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ModalURLSpan.kt new file mode 100644 index 0000000000..358dd56357 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ModalURLSpan.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.text.style.URLSpan +import android.view.View + +class ModalURLSpan(url: String, private val openModalCallback: (String)->Unit): URLSpan(url) { + override fun onClick(widget: View) { + openModalCallback(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt new file mode 100644 index 0000000000..b7ced4abb3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.graphics.Rect +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.view.MotionEvent +import android.widget.TextView +import androidx.core.text.getSpans +import androidx.core.text.toSpannable + +object TextUtilities { + + fun getIntrinsicHeight(text: CharSequence, paint: TextPaint, width: Int): Int { + val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.0f, 1.0f) + .setIncludePad(false) + val layout = builder.build() + return layout.height + } + + fun TextView.getIntersectedModalSpans(event: MotionEvent): List { + val xInt = event.rawX.toInt() + val yInt = event.rawY.toInt() + val hitRect = Rect(xInt, yInt, xInt, yInt) + return getIntersectedModalSpans(hitRect) + } + + fun TextView.getIntersectedModalSpans(hitRect: Rect): List { + val textLayout = layout ?: return emptyList() + val lineRect = Rect() + val bodyTextRect = Rect() + getGlobalVisibleRect(bodyTextRect) + val textSpannable = text.toSpannable() + return (0 until textLayout.lineCount).flatMap { line -> + textLayout.getLineBounds(line, lineRect) + lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop) + if ((Rect(lineRect)).contains(hitRect)) { + // calculate the url span intersected with (if any) + val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same + textSpannable.getSpans(off, off).toList() + } else { + emptyList() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailDimensDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailDimensDelegate.kt new file mode 100644 index 0000000000..fb50d8de62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailDimensDelegate.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +class ThumbnailDimensDelegate { + + companion object { + // dimens array constants + private const val WIDTH = 0 + private const val HEIGHT = 1 + private const val DIMENS_ARRAY_SIZE = 2 + + // bounds array constants + private const val MIN_WIDTH = 0 + private const val MIN_HEIGHT = 1 + private const val MAX_WIDTH = 2 + private const val MAX_HEIGHT = 3 + private const val BOUNDS_ARRAY_SIZE = 4 + + // const zero int array + private val EMPTY_DIMENS = intArrayOf(0,0) + + } + + private val measured: IntArray = IntArray(DIMENS_ARRAY_SIZE) + private val dimens: IntArray = IntArray(DIMENS_ARRAY_SIZE) + private val bounds: IntArray = IntArray(BOUNDS_ARRAY_SIZE) + + fun resourceSize(): IntArray { + if (dimens.all { it == 0 }) { + // dimens are (0, 0), don't go any further + return EMPTY_DIMENS + } + + val naturalWidth = dimens[WIDTH].toDouble() + val naturalHeight = dimens[HEIGHT].toDouble() + val minWidth = dimens[MIN_WIDTH] + val maxWidth = dimens[MAX_WIDTH] + val minHeight = dimens[MIN_HEIGHT] + val maxHeight = dimens[MAX_HEIGHT] + + // calculate actual measured + var measuredWidth: Double = naturalWidth + var measuredHeight: Double = naturalHeight + + val widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth + val heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight + + if (!widthInBounds || !heightInBounds) { + val minWidthRatio: Double = naturalWidth / minWidth + val maxWidthRatio: Double = naturalWidth / maxWidth + val minHeightRatio: Double = naturalHeight / minHeight + val maxHeightRatio: Double = naturalHeight / maxHeight + if (maxWidthRatio > 1 || maxHeightRatio > 1) { + if (maxWidthRatio >= maxHeightRatio) { + measuredWidth /= maxWidthRatio + measuredHeight /= maxWidthRatio + } else { + measuredWidth /= maxHeightRatio + measuredHeight /= maxHeightRatio + } + measuredWidth = Math.max(measuredWidth, minWidth.toDouble()) + measuredHeight = Math.max(measuredHeight, minHeight.toDouble()) + } else if (minWidthRatio < 1 || minHeightRatio < 1) { + if (minWidthRatio <= minHeightRatio) { + measuredWidth /= minWidthRatio + measuredHeight /= minWidthRatio + } else { + measuredWidth /= minHeightRatio + measuredHeight /= minHeightRatio + } + measuredWidth = Math.min(measuredWidth, maxWidth.toDouble()) + measuredHeight = Math.min(measuredHeight, maxHeight.toDouble()) + } + } + measured[WIDTH] = measuredWidth.toInt() + measured[HEIGHT] = measuredHeight.toInt() + return measured + } + + fun setBounds(minWidth: Int, minHeight: Int, maxWidth: Int, maxHeight: Int) { + bounds[MIN_WIDTH] = minWidth + bounds[MIN_HEIGHT] = minHeight + bounds[MAX_WIDTH] = maxWidth + bounds[MAX_HEIGHT] = maxHeight + } + + fun setDimens(width: Int, height: Int) { + dimens[WIDTH] = width + dimens[HEIGHT] = height + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt new file mode 100644 index 0000000000..60ef8116d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.conversation.v2.utilities + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Interpolator +import android.graphics.Paint +import android.graphics.Rect +import android.os.SystemClock +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.Animation +import android.view.animation.AnimationSet +import android.view.animation.AnimationUtils +import androidx.core.content.res.ResourcesCompat +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import network.loki.messenger.R +import kotlin.math.sin + +class ThumbnailProgressBar: View { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + private val firstX: Double + get() = sin(SystemClock.elapsedRealtime() / 300.0) * 1.5 + + private val secondX: Double + get() = sin(SystemClock.elapsedRealtime() / 300.0 + (Math.PI/4)) * 1.5 + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = ResourcesCompat.getColor(resources, R.color.accent, null) + } + + private val objectRect = Rect() + private val drawingRect = Rect() + + override fun dispatchDraw(canvas: Canvas?) { + if (canvas == null) return + + getDrawingRect(objectRect) + drawingRect.set(objectRect) + + val coercedFX = firstX + val coercedSX = secondX + + val firstMeasuredX = objectRect.left + (objectRect.width() * coercedFX) + val secondMeasuredX = objectRect.left + (objectRect.width() * coercedSX) + + drawingRect.set( + (if (firstMeasuredX < secondMeasuredX) firstMeasuredX else secondMeasuredX).toInt(), + objectRect.top, + (if (firstMeasuredX < secondMeasuredX) secondMeasuredX else firstMeasuredX).toInt(), + objectRect.bottom + ) + + canvas.drawRect(drawingRect, paint) + invalidate() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java index 106fa45096..f40a57924a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components; +package org.thoughtcrime.securesms.conversation.v2.utilities; import android.content.Context; import android.content.res.TypedArray; @@ -24,6 +24,9 @@ import com.bumptech.glide.request.RequestOptions; import network.loki.messenger.R; +import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget; +import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget; +import org.thoughtcrime.securesms.components.TransferControlView; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequest; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -54,7 +57,6 @@ public class ThumbnailView extends FrameLayout { private ImageView image; private View playOverlay; - private View captionIcon; private View loadIndicator; private OnClickListener parentClickListener; @@ -67,7 +69,7 @@ public class ThumbnailView extends FrameLayout { private SlidesClickedListener downloadClickListener = null; private Slide slide = null; - private int radius; + public int radius; public ThumbnailView(Context context) { this(context, null); @@ -84,7 +86,6 @@ public class ThumbnailView extends FrameLayout { this.image = findViewById(R.id.thumbnail_image); this.playOverlay = findViewById(R.id.play_overlay); - this.captionIcon = findViewById(R.id.thumbnail_caption_icon); this.loadIndicator = findViewById(R.id.thumbnail_load_indicator); super.setOnClickListener(new ThumbnailClickDispatcher()); @@ -94,10 +95,10 @@ public class ThumbnailView extends FrameLayout { bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0); bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); - radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius)); + radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0); typedArray.recycle(); } else { - radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius); + radius = 0; } } @@ -275,8 +276,6 @@ public class ThumbnailView extends FrameLayout { this.slide = slide; - this.captionIcon.setVisibility(slide.getCaption().isPresent() ? VISIBLE : GONE); - dimens[WIDTH] = naturalWidth; dimens[HEIGHT] = naturalHeight; invalidate(); @@ -398,6 +397,7 @@ public class ThumbnailView extends FrameLayout { } private class ThumbnailClickDispatcher implements View.OnClickListener { + @Override public void onClick(View view) { if (thumbnailClickListener != null && @@ -413,9 +413,9 @@ public class ThumbnailView extends FrameLayout { } private class DownloadClickDispatcher implements View.OnClickListener { + @Override public void onClick(View view) { - Log.i(TAG, "onClick() for download button"); if (downloadClickListener != null && slide != null) { downloadClickListener.onClick(view, Collections.singletonList(slide)); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 75dca96013..ce2ddba24a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -34,42 +34,40 @@ import net.sqlcipher.database.SQLiteDatabase; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; +import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; +import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage; +import org.session.libsession.messaging.sending_receiving.attachments.Attachment; +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; +import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.GroupUtil; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsession.utilities.NetworkFailure; import org.session.libsession.utilities.NetworkFailureList; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsession.utilities.recipients.RecipientFormattingException; +import org.session.libsignal.utilities.JsonUtil; +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.ThreadUtils; +import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; -import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.MmsException; -import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; -import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.mms.SlideDeck; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId; -import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; -import org.session.libsession.utilities.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientFormattingException; -import org.session.libsignal.utilities.JsonUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; - -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; - import java.io.Closeable; import java.io.IOException; import java.security.SecureRandom; @@ -884,9 +882,9 @@ public class MmsDatabase extends MessagingDatabase { } public boolean delete(long messageId) { - long threadId = getThreadIdForMessage(messageId); + long threadId = getThreadIdForMessage(messageId); AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); - attachmentDatabase.deleteAttachmentsForMessage(messageId); + ThreadUtils.queue(() -> attachmentDatabase.deleteAttachmentsForMessage(messageId)); GroupReceiptDatabase groupReceiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); groupReceiptDatabase.deleteRowsForMessage(messageId); @@ -1171,9 +1169,9 @@ public class MmsDatabase extends MessagingDatabase { return new NotificationMmsMessageRecord(id, recipient, recipient, - addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, + dateSent, dateReceived, deliveryReceiptCount, threadId, contentLocationBytes, messageSize, expiry, status, - transactionIdBytes, mailbox, subscriptionId, slideDeck, + transactionIdBytes, mailbox, slideDeck, readReceiptCount); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index f21b1ae622..42284a4ff0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -129,7 +129,17 @@ public class MmsSmsDatabase extends Database { String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - return queryTables(PROJECTION, selection, order, "1"); + return queryTables(PROJECTION, selection, order, "1"); + } + + public long getLastMessageID(long threadId) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; + + try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { + cursor.moveToFirst(); + return cursor.getLong(cursor.getColumnIndex(MmsSmsColumns.ID)); + } } public Cursor getUnread() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index dfbc39f156..6706f5fe77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -20,6 +20,8 @@ package org.thoughtcrime.securesms.database; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.os.Handler; +import android.os.Looper; import android.text.TextUtils; import android.util.Pair; @@ -28,23 +30,21 @@ import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteStatement; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; -import org.session.libsession.utilities.IdentityKeyMismatch; -import org.session.libsession.utilities.IdentityKeyMismatchList; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.session.libsession.messaging.messages.signal.IncomingGroupMessage; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; - import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.JsonUtil; +import org.session.libsession.utilities.IdentityKeyMismatch; +import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsignal.utilities.JsonUtil; +import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import java.io.IOException; import java.security.SecureRandom; @@ -413,7 +413,6 @@ public class SmsDatabase extends MessagingDatabase { notifyConversationListeners(threadId); - return Optional.of(new InsertResult(messageId, threadId)); } } @@ -514,7 +513,7 @@ public class SmsDatabase extends MessagingDatabase { public boolean deleteMessage(long messageId) { Log.i("MessageDatabase", "Deleting: " + messageId); SQLiteDatabase db = databaseHelper.getWritableDatabase(); - long threadId = getThreadIdForMessage(messageId); + long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false); notifyConversationListeners(threadId); @@ -641,10 +640,10 @@ public class SmsDatabase extends MessagingDatabase { public MessageRecord getCurrent() { return new SmsMessageRecord(id, message.getMessageBody(), message.getRecipient(), message.getRecipient(), - 1, System.currentTimeMillis(), System.currentTimeMillis(), + System.currentTimeMillis(), System.currentTimeMillis(), 0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), threadId, 0, new LinkedList(), - message.getSubscriptionId(), message.getExpiresIn(), + message.getExpiresIn(), System.currentTimeMillis(), 0, false); } } @@ -696,9 +695,8 @@ public class SmsDatabase extends MessagingDatabase { return new SmsMessageRecord(messageId, body, recipient, recipient, - addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, type, - threadId, status, mismatches, subscriptionId, + threadId, status, mismatches, expiresIn, expireStarted, readReceiptCount, unidentified); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 1c67eac4e3..15b91048e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.database -import android.app.job.JobScheduler import android.content.Context import android.net.Uri import org.session.libsession.database.StorageProtocol @@ -28,6 +27,7 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.loki.api.OpenGroupManager import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase @@ -105,7 +105,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } else -> Optional.absent() } - val pointerAttachments = attachments.mapNotNull { + val pointers = attachments.mapNotNull { it.toSignalAttachment() } val targetAddress = if (isUserSender && !message.syncTarget.isNullOrEmpty()) { @@ -121,7 +121,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val linkPreviews: Optional> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val mmsDatabase = DatabaseFactory.getMmsDatabase(context) val insertResult = if (message.sender == getUserPublicKey()) { - val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointerAttachments, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) + val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment @@ -304,6 +304,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } + override fun markAsSending(timestamp: Long, author: String) { + val database = DatabaseFactory.getMmsSmsDatabase(context) + val messageRecord = database.getMessageFor(timestamp, author) ?: return + if (messageRecord.isMms) { + val mmsDatabase = DatabaseFactory.getMmsDatabase(context) + mmsDatabase.markAsSending(messageRecord.getId()) + } else { + val smsDatabase = DatabaseFactory.getSmsDatabase(context) + smsDatabase.markAsSending(messageRecord.getId()) + messageRecord.isPending + } + } + override fun markUnidentified(timestamp: Long, author: String) { val database = DatabaseFactory.getMmsSmsDatabase(context) val messageRecord = database.getMessageFor(timestamp, author) ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 2ba85ca66b..5643340ec9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -582,7 +582,7 @@ public class ThreadDatabase extends Database { } private @Nullable Uri getAttachmentUriFor(MessageRecord record) { - if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null; + if (!record.isMms() || record.isMmsNotification()) return null; SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); Slide thumbnail = slideDeck.getThumbnailSlide(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index f592d47e9d..3adb9cbda5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -17,12 +17,13 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import androidx.annotation.NonNull; import android.text.SpannableString; +import androidx.annotation.NonNull; + +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; -import org.session.libsession.utilities.recipients.Recipient; /** * The base class for all message record models. Encapsulates basic data @@ -33,9 +34,7 @@ import org.session.libsession.utilities.recipients.Recipient; */ public abstract class DisplayRecord { - protected final long type; - private final Recipient recipient; private final long dateSent; private final long dateReceived; @@ -46,8 +45,8 @@ public abstract class DisplayRecord { private final int readReceiptCount; DisplayRecord(String body, Recipient recipient, long dateSent, - long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, - long type, int readReceiptCount) + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, int readReceiptCount) { this.threadId = threadId; this.recipient = recipient; @@ -63,138 +62,63 @@ public abstract class DisplayRecord { public @NonNull String getBody() { return body == null ? "" : body; } + public abstract SpannableString getDisplayBody(@NonNull Context context); + public Recipient getRecipient() { return recipient; } + public long getDateSent() { return dateSent; } + public long getDateReceived() { return dateReceived; } + public long getThreadId() { return threadId; } + public int getDeliveryStatus() { return deliveryStatus; } + public int getDeliveryReceiptCount() { return deliveryReceiptCount; } + public int getReadReceiptCount() { return readReceiptCount; } + + public boolean isDelivered() { + return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE + && deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; + } + + public boolean isSent() { + return !isFailed() && !isPending(); + } public boolean isFailed() { - return - MmsSmsColumns.Types.isFailedMessageType(type) || - MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) || - deliveryStatus >= SmsDatabase.Status.STATUS_FAILED; + return MmsSmsColumns.Types.isFailedMessageType(type) + || MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) + || deliveryStatus >= SmsDatabase.Status.STATUS_FAILED; } public boolean isPending() { - return MmsSmsColumns.Types.isPendingMessageType(type) && - !MmsSmsColumns.Types.isIdentityVerified(type) && - !MmsSmsColumns.Types.isIdentityDefault(type); + return MmsSmsColumns.Types.isPendingMessageType(type) + && !MmsSmsColumns.Types.isIdentityVerified(type) + && !MmsSmsColumns.Types.isIdentityDefault(type); } + public boolean isRead() { return readReceiptCount > 0; } + public boolean isOutgoing() { return MmsSmsColumns.Types.isOutgoingMessageType(type); } - - public abstract SpannableString getDisplayBody(@NonNull Context context); - - public Recipient getRecipient() { - return recipient; - } - - public long getDateSent() { - return dateSent; - } - - public long getDateReceived() { - return dateReceived; - } - - public long getThreadId() { - return threadId; - } - - public boolean isKeyExchange() { - return SmsDatabase.Types.isKeyExchangeType(type); - } - - public boolean isEndSession() { return SmsDatabase.Types.isEndSessionType(type); } - - public boolean isLokiSessionRestoreSent() { return SmsDatabase.Types.isLokiSessionRestoreSentType(type); } - - public boolean isLokiSessionRestoreDone() { return SmsDatabase.Types.isLokiSessionRestoreDoneType(type); } - - // TODO isGroupUpdate and isGroupQuit are kept for compatibility with old update messages, they can be removed later on - public boolean isGroupUpdate() { - return SmsDatabase.Types.isGroupUpdate(type); - } - - public boolean isGroupQuit() { - return SmsDatabase.Types.isGroupQuit(type); - } - public boolean isGroupUpdateMessage() { return SmsDatabase.Types.isGroupUpdateMessage(type); } - - //TODO isGroupAction can be replaced by isGroupUpdateMessage in the code when the 2 functions above are removed - public boolean isGroupAction() { - return isGroupUpdate() || isGroupQuit() || isGroupUpdateMessage(); - } - - public boolean isExpirationTimerUpdate() { - return SmsDatabase.Types.isExpirationTimerUpdate(type); - } - - // Data extraction - - public boolean isMediaSavedExtraction() { - return MmsSmsColumns.Types.isMediaSavedExtraction(type); - } - - public boolean isScreenshotExtraction() { - return MmsSmsColumns.Types.isScreenshotExtraction(type); - } - - public boolean isDataExtraction() { - return isMediaSavedExtraction() || isScreenshotExtraction(); - } - - public boolean isOpenGroupInvitation() { - return MmsSmsColumns.Types.isOpenGroupInvitation(type); - } - + public boolean isExpirationTimerUpdate() { return SmsDatabase.Types.isExpirationTimerUpdate(type); } + public boolean isMediaSavedNotification() { return MmsSmsColumns.Types.isMediaSavedExtraction(type); } + public boolean isScreenshotNotification() { return MmsSmsColumns.Types.isScreenshotExtraction(type); } + public boolean isDataExtractionNotification() { return isMediaSavedNotification() || isScreenshotNotification(); } + public boolean isOpenGroupInvitation() { return MmsSmsColumns.Types.isOpenGroupInvitation(type); } public boolean isCallLog() { return SmsDatabase.Types.isCallLog(type); } - - public boolean isJoined() { - return SmsDatabase.Types.isJoinedType(type); - } - public boolean isIncomingCall() { return SmsDatabase.Types.isIncomingCall(type); } - public boolean isOutgoingCall() { return SmsDatabase.Types.isOutgoingCall(type); } - public boolean isMissedCall() { return SmsDatabase.Types.isMissedCall(type); } - public boolean isVerificationStatusChange() { - return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type); - } - - public int getDeliveryStatus() { - return deliveryStatus; - } - - public int getDeliveryReceiptCount() { - return deliveryReceiptCount; - } - - public int getReadReceiptCount() { - return readReceiptCount; - } - - public boolean isDelivered() { - return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE && - deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0; - } - - public boolean isRemoteRead() { - return readReceiptCount > 0; - } - - public boolean isPendingInsecureSmsFallback() { - return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type); + public boolean isControlMessage() { + return isGroupUpdateMessage() || isExpirationTimerUpdate() || isDataExtractionNotification(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 97b7329bc4..3385ba3a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -17,21 +17,19 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; +import android.text.SpannableString; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.text.SpannableString; - -import network.loki.messenger.R; -import org.session.libsession.utilities.Contact; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; -import org.thoughtcrime.securesms.mms.SlideDeck; import org.session.libsession.utilities.recipients.Recipient; - +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.thoughtcrime.securesms.mms.SlideDeck; import java.util.List; +import network.loki.messenger.R; /** * Represents the message record model for MMS messages that contain @@ -42,26 +40,24 @@ import java.util.List; */ public class MediaMmsMessageRecord extends MmsMessageRecord { - private final static String TAG = MediaMmsMessageRecord.class.getSimpleName(); - - private final int partCount; + private final int partCount; public MediaMmsMessageRecord(long id, Recipient conversationRecipient, - Recipient individualRecipient, int recipientDeviceId, - long dateSent, long dateReceived, int deliveryReceiptCount, - long threadId, String body, - @NonNull SlideDeck slideDeck, - int partCount, long mailbox, - List mismatches, - List failures, int subscriptionId, - long expiresIn, long expireStarted, int readReceiptCount, - @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, boolean unidentified) + Recipient individualRecipient, int recipientDeviceId, + long dateSent, long dateReceived, int deliveryReceiptCount, + long threadId, String body, + @NonNull SlideDeck slideDeck, + int partCount, long mailbox, + List mismatches, + List failures, int subscriptionId, + long expiresIn, long expireStarted, int readReceiptCount, + @Nullable Quote quote, @NonNull List contacts, + @NonNull List linkPreviews, boolean unidentified) { - super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, - dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, - subscriptionId, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, - linkPreviews, unidentified); + super(id, body, conversationRecipient, individualRecipient, dateSent, + dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, + expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, + linkPreviews, unidentified); this.partCount = partCount; } @@ -82,8 +78,6 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); } else if (MmsDatabase.Types.isNoRemoteSessionType(type)) { return emphasisAdded(context.getString(R.string.MmsMessageRecord_mms_message_encrypted_for_non_existing_session)); - } else if (isLegacyMessage()) { - return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); } return super.getDisplayBody(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 13a9b4911a..31db5b4514 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -17,22 +17,18 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import androidx.annotation.NonNull; import android.text.Spannable; import android.text.SpannableString; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; -import network.loki.messenger.R; +import androidx.annotation.NonNull; import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage; import org.session.libsession.messaging.utilities.UpdateMessageBuilder; import org.session.libsession.messaging.utilities.UpdateMessageData; -import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.SmsDatabase; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; - import org.session.libsession.utilities.recipients.Recipient; import java.util.List; @@ -46,145 +42,79 @@ import java.util.List; * */ public abstract class MessageRecord extends DisplayRecord { - private final Recipient individualRecipient; - private final int recipientDeviceId; - public final long id; private final List mismatches; private final List networkFailures; - private final int subscriptionId; private final long expiresIn; private final long expireStarted; private final boolean unidentified; + public final long id; + + public abstract boolean isMms(); + public abstract boolean isMmsNotification(); MessageRecord(long id, String body, Recipient conversationRecipient, - Recipient individualRecipient, int recipientDeviceId, - long dateSent, long dateReceived, long threadId, - int deliveryStatus, int deliveryReceiptCount, long type, - List mismatches, - List networkFailures, - int subscriptionId, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified) + Recipient individualRecipient, + long dateSent, long dateReceived, long threadId, + int deliveryStatus, int deliveryReceiptCount, long type, + List mismatches, + List networkFailures, + long expiresIn, long expireStarted, + int readReceiptCount, boolean unidentified) { super(body, conversationRecipient, dateSent, dateReceived, - threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); + threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); this.id = id; this.individualRecipient = individualRecipient; - this.recipientDeviceId = recipientDeviceId; this.mismatches = mismatches; this.networkFailures = networkFailures; - this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; this.expireStarted = expireStarted; this.unidentified = unidentified; } - public abstract boolean isMms(); - public abstract boolean isMmsNotification(); - - public boolean isSecure() { - return MmsSmsColumns.Types.isSecureType(type); - } - - public boolean isLegacyMessage() { - return MmsSmsColumns.Types.isLegacyType(type); - } - - @Override - public SpannableString getDisplayBody(@NonNull Context context) { - if(isGroupUpdateMessage()) { - UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); - } else if (isExpirationTimerUpdate()) { - int seconds = (int) (getExpiresIn() / 1000); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getIndividualRecipient().getAddress().serialize(), isOutgoing())); - } else if (isDataExtraction()) { - if (isScreenshotExtraction()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); - else if (isMediaSavedExtraction()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); - } - // TODO below lines are left here for compatibility with older group update messages, it can be deleted later on - else if (isGroupUpdate() && isOutgoing()) { - return new SpannableString(context.getString(R.string.MessageRecord_you_updated_group)); - } else if (isGroupUpdate()) { - return new SpannableString(context.getString(R.string.MessageRecord_s_updated_group, getIndividualRecipient().toShortString())); - } else if (isGroupQuit() && isOutgoing()) { - return new SpannableString(context.getString(R.string.MessageRecord_left_group)); - } else if (isGroupQuit()) { - return new SpannableString(context.getString(R.string.ConversationItem_group_action_left, getIndividualRecipient().toShortString())); - } - - return new SpannableString(getBody()); - } - public long getId() { return id; } - - public boolean isPush() { - return SmsDatabase.Types.isPushType(type) && !SmsDatabase.Types.isForcedSms(type); - } - public long getTimestamp() { - if (getRecipient().getAddress().isOpenGroup()) { - return getDateReceived(); - } - if (isPush() && getDateSent() < getDateReceived()) { - return getDateSent(); - } - return getDateReceived(); + return getDateSent(); } - - public boolean isForcedSms() { - return SmsDatabase.Types.isForcedSms(type); + public Recipient getIndividualRecipient() { + return individualRecipient; } - - public boolean isIdentityVerified() { - return SmsDatabase.Types.isIdentityVerified(type); + public long getType() { + return type; } - - public boolean isIdentityDefault() { - return SmsDatabase.Types.isIdentityDefault(type); + public List getNetworkFailures() { + return networkFailures; } - - public boolean isBundleKeyExchange() { - return SmsDatabase.Types.isBundleKeyExchange(type); - } - - public boolean isIdentityUpdate() { - return SmsDatabase.Types.isIdentityUpdate(type); - } - - public boolean isCorruptedKeyExchange() { - return SmsDatabase.Types.isCorruptedKeyExchange(type); - } - - public boolean isInvalidVersionKeyExchange() { - return SmsDatabase.Types.isInvalidVersionKeyExchange(type); - } - - public boolean isUpdate() { - return isGroupAction() || isJoined() || isExpirationTimerUpdate() || isCallLog() || isDataExtraction() || - isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() || isLokiSessionRestoreSent() || isLokiSessionRestoreDone(); + public long getExpiresIn() { + return expiresIn; } + public long getExpireStarted() { return expireStarted; } public boolean isMediaPending() { return false; } - public Recipient getIndividualRecipient() { - return individualRecipient; + public boolean isUpdate() { + return isExpirationTimerUpdate() || isCallLog() || isDataExtractionNotification(); } - public long getType() { - return type; - } + @Override + public SpannableString getDisplayBody(@NonNull Context context) { + if (isGroupUpdateMessage()) { + UpdateMessageData updateMessageData = UpdateMessageData.Companion.fromJSON(getBody()); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + } else if (isExpirationTimerUpdate()) { + int seconds = (int) (getExpiresIn() / 1000); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + } else if (isDataExtractionNotification()) { + if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); + else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); + } - public List getIdentityKeyMismatches() { - return mismatches; - } - - public List getNetworkFailures() { - return networkFailures; + return new SpannableString(getBody()); } protected SpannableString emphasisAdded(String sequence) { @@ -196,25 +126,12 @@ public abstract class MessageRecord extends DisplayRecord { } public boolean equals(Object other) { - return other != null && - other instanceof MessageRecord && - ((MessageRecord) other).getId() == getId() && - ((MessageRecord) other).isMms() == isMms(); + return other instanceof MessageRecord + && ((MessageRecord) other).getId() == getId() + && ((MessageRecord) other).isMms() == isMms(); } public int hashCode() { return (int)getId(); } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStarted() { - return expireStarted; - } - - public boolean isUnidentified() { - return unidentified; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 528720547c..937b74ec58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -1,43 +1,35 @@ package org.thoughtcrime.securesms.database.model; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import org.session.libsession.utilities.Contact; import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; import org.session.libsession.utilities.recipients.Recipient; - import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; - - import java.util.LinkedList; import java.util.List; public abstract class MmsMessageRecord extends MessageRecord { - private final @NonNull SlideDeck slideDeck; private final @Nullable Quote quote; private final @NonNull List contacts = new LinkedList<>(); private final @NonNull List linkPreviews = new LinkedList<>(); MmsMessageRecord(long id, String body, Recipient conversationRecipient, - Recipient individualRecipient, int recipientDeviceId, long dateSent, - long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, - long type, List mismatches, - List networkFailures, int subscriptionId, long expiresIn, - long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, - @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, boolean unidentified) + Recipient individualRecipient, long dateSent, + long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount, + long type, List mismatches, + List networkFailures, long expiresIn, + long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, + @Nullable Quote quote, @NonNull List contacts, + @NonNull List linkPreviews, boolean unidentified) { - super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified); - + super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified); this.slideDeck = slideDeck; this.quote = quote; - this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); } @@ -66,15 +58,12 @@ public abstract class MmsMessageRecord extends MessageRecord { public boolean containsMediaSlide() { return slideDeck.containsMediaSlide(); } - public @Nullable Quote getQuote() { return quote; } - public @NonNull List getSharedContacts() { return contacts; } - public @NonNull List getLinkPreviews() { return linkPreviews; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 361be34d39..4c3aa98868 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -17,19 +17,17 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; -import androidx.annotation.NonNull; import android.text.SpannableString; - -import network.loki.messenger.R; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import androidx.annotation.NonNull; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; -import org.thoughtcrime.securesms.mms.SlideDeck; import org.session.libsession.utilities.recipients.Recipient; - +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase.Status; +import org.thoughtcrime.securesms.mms.SlideDeck; import java.util.Collections; import java.util.LinkedList; +import network.loki.messenger.R; /** * Represents the message record model for MMS messages that are @@ -40,7 +38,6 @@ import java.util.LinkedList; */ public class NotificationMmsMessageRecord extends MmsMessageRecord { - private final byte[] contentLocation; private final long messageSize; private final long expiry; @@ -48,16 +45,16 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { private final byte[] transactionId; public NotificationMmsMessageRecord(long id, Recipient conversationRecipient, - Recipient individualRecipient, int recipientDeviceId, - long dateSent, long dateReceived, int deliveryReceiptCount, - long threadId, byte[] contentLocation, long messageSize, - long expiry, int status, byte[] transactionId, long mailbox, - int subscriptionId, SlideDeck slideDeck, int readReceiptCount) + Recipient individualRecipient, + long dateSent, long dateReceived, int deliveryReceiptCount, + long threadId, byte[] contentLocation, long messageSize, + long expiry, int status, byte[] transactionId, long mailbox, + SlideDeck slideDeck, int readReceiptCount) { - super(id, "", conversationRecipient, individualRecipient, recipientDeviceId, - dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, - new LinkedList(), new LinkedList(), subscriptionId, - 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false); + super(id, "", conversationRecipient, individualRecipient, + dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, + new LinkedList(), new LinkedList(), + 0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false); this.contentLocation = contentLocation; this.messageSize = messageSize; @@ -69,19 +66,15 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { public byte[] getTransactionId() { return transactionId; } - public int getStatus() { return this.status; } - public byte[] getContentLocation() { return contentLocation; } - public long getMessageSize() { return (messageSize + 1023) / 1024; } - public long getExpiration() { return expiry * 1000; } @@ -91,11 +84,6 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { return false; } - @Override - public boolean isSecure() { - return false; - } - @Override public boolean isPending() { return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 08328aaaef..319ff6fcaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -19,17 +19,12 @@ package org.thoughtcrime.securesms.database.model; import android.content.Context; import android.text.SpannableString; - import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.database.MmsSmsColumns; -import org.thoughtcrime.securesms.database.SmsDatabase; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.recipients.Recipient; - +import org.thoughtcrime.securesms.database.SmsDatabase; import java.util.LinkedList; import java.util.List; - import network.loki.messenger.R; /** @@ -41,20 +36,19 @@ import network.loki.messenger.R; public class SmsMessageRecord extends MessageRecord { public SmsMessageRecord(long id, - String body, Recipient recipient, - Recipient individualRecipient, - int recipientDeviceId, - long dateSent, long dateReceived, - int deliveryReceiptCount, - long type, long threadId, - int status, List mismatches, - int subscriptionId, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified) + String body, Recipient recipient, + Recipient individualRecipient, + long dateSent, long dateReceived, + int deliveryReceiptCount, + long type, long threadId, + int status, List mismatches, + long expiresIn, long expireStarted, + int readReceiptCount, boolean unidentified) { - super(id, body, recipient, individualRecipient, recipientDeviceId, - dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, - mismatches, new LinkedList<>(), subscriptionId, - expiresIn, expireStarted, readReceiptCount, unidentified); + super(id, body, recipient, individualRecipient, + dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, + mismatches, new LinkedList<>(), + expiresIn, expireStarted, readReceiptCount, unidentified); } public long getType() { @@ -63,33 +57,12 @@ public class SmsMessageRecord extends MessageRecord { @Override public SpannableString getDisplayBody(@NonNull Context context) { - Recipient recipient = getRecipient(); if (SmsDatabase.Types.isFailedDecryptType(type)) { return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message)); - } else if (isCorruptedKeyExchange()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_corrupted_key_exchange_message)); - } else if (isInvalidVersionKeyExchange()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_key_exchange_message_for_invalid_protocol_version)); - } else if (MmsSmsColumns.Types.isLegacyType(type)) { - return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported)); - } else if (isBundleKeyExchange()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_message_with_new_safety_number_tap_to_process)); - } else if (isKeyExchange() && isOutgoing()) { - return new SpannableString(""); - } else if (isKeyExchange() && !isOutgoing()) { - return emphasisAdded(context.getString(R.string.ConversationItem_received_key_exchange_message_tap_to_process)); } else if (SmsDatabase.Types.isDuplicateMessageType(type)) { return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message)); } else if (SmsDatabase.Types.isNoRemoteSessionType(type)) { return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session)); - } else if (isLokiSessionRestoreSent()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset)); - } else if (isLokiSessionRestoreDone()) { - return emphasisAdded(context.getString(R.string.view_reset_secure_session_done_message)); - } else if (isEndSession() && isOutgoing()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset)); - } else if (isEndSession()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset_s, getIndividualRecipient().toShortString())); } else { return super.getDisplayBody(context); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index c5091651a2..91c2729573 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -73,22 +73,14 @@ public class ThreadRecord extends DisplayRecord { @Override public SpannableString getDisplayBody(@NonNull Context context) { Recipient recipient = getRecipient(); - if (isGroupUpdate() || isGroupUpdateMessage()) { + if (isGroupUpdateMessage()) { return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); - } else if (isGroupQuit()) { - return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group)); } else if (isOpenGroupInvitation()) { return emphasisAdded(context.getString(R.string.ThreadRecord_open_group_invitation)); - } else if (isKeyExchange()) { - return emphasisAdded(context.getString(R.string.ConversationListItem_key_exchange_message)); } else if (SmsDatabase.Types.isFailedDecryptType(type)) { return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message)); } else if (SmsDatabase.Types.isNoRemoteSessionType(type)) { return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session)); - } else if (isLokiSessionRestoreSent()) { - return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset)); - } else if (isLokiSessionRestoreDone()) { - return emphasisAdded(context.getString(R.string.view_reset_secure_session_done_message)); } else if (SmsDatabase.Types.isEndSessionType(type)) { return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset)); } else if (MmsSmsColumns.Types.isLegacyType(type)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index a6f4c801fb..dd2bd7735d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -95,7 +95,7 @@ public class LinkPreviewRepository implements InjectableType { private @NonNull RequestController fetchMetadata(@NonNull String url, Callback callback) { Call call = client.newCall(new Request.Builder().url(url).removeHeader("User-Agent").addHeader("User-Agent", - "WhatsApp").cacheControl(NO_CACHE).build()); + "WhatsApp").cacheControl(NO_CACHE).build()); call.enqueue(new okhttp3.Callback() { @Override @@ -186,18 +186,18 @@ public class LinkPreviewRepository implements InjectableType { byte[] bytes = baos.toByteArray(); Uri uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(); - return Optional.of(new UriAttachment(uri, - uri, - contentType, - AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, - bytes.length, - bitmap.getWidth(), - bitmap.getHeight(), - null, - null, - false, - false, - null)); + return Optional.of(new UriAttachment(uri, + uri, + contentType, + AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED, + bytes.length, + bitmap.getWidth(), + bitmap.getHeight(), + null, + null, + false, + false, + null)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java index dbf8c4bf66..8556c232a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java @@ -74,7 +74,7 @@ public class LinkPreviewViewModel extends ViewModel { activeRequest = null; } - if (!link.isPresent() || !isCursorPositionValid(text, link.get(), cursorStart, cursorEnd)) { + if (!link.isPresent()) { activeUrl = null; linkPreviewState.setValue(LinkPreviewState.forEmpty()); return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt index f13dee1d46..df4f3ce614 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreateClosedGroupActivity.kt @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.loki.utilities.fadeOut import org.thoughtcrime.securesms.mms.GlideApp import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 //TODO Refactor to avoid using kotlinx.android.synthetic class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderManager.LoaderCallbacks> { @@ -135,10 +136,10 @@ class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), LoaderM // region Convenience private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivity::class.java) - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId) + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, DistributionTypes.DEFAULT) - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) context.startActivity(intent) } // endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt index 54c80227a2..7d429bf223 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/CreatePrivateChatActivity.kt @@ -7,35 +7,44 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter import android.text.InputType +import android.util.Log +import android.util.TypedValue import android.view.* import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast -import kotlinx.android.synthetic.main.activity_create_private_chat.loader -import kotlinx.android.synthetic.main.activity_create_private_chat.tabLayout -import kotlinx.android.synthetic.main.activity_create_private_chat.viewPager +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import kotlinx.android.synthetic.main.activity_create_private_chat.* import kotlinx.android.synthetic.main.fragment_enter_public_key.* import network.loki.messenger.R import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.snode.SnodeAPI -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.ConversationActivity import org.session.libsession.utilities.Address import org.session.libsession.utilities.DistributionTypes +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragmentDelegate -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.PublicKeyValidation - class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { private val adapter = CreatePrivateChatActivityAdapter(this) + private var isKeyboardShowing = false + set(value) { + val hasChanged = (field != value) + field = value + if (hasChanged) { + adapter.isKeyboardShowing = value + } + } // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { @@ -47,11 +56,15 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC // Set up view pager viewPager.adapter = adapter tabLayout.setupWithViewPager(viewPager) - } + rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_done, menu) - return true + override fun onGlobalLayout() { + val diff = rootLayout.rootView.height - rootLayout.height + val displayMetrics = this@CreatePrivateChatActivity.resources.displayMetrics + val estimatedKeyboardHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200.0f, displayMetrics) + this@CreatePrivateChatActivity.isKeyboardShowing = (diff > estimatedKeyboardHeight) + } + }) } // endregion @@ -73,13 +86,6 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC // endregion // region Interaction - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.doneButton -> adapter.enterPublicKeyFragment.createPrivateChatIfPossible() - } - return super.onOptionsItemSelected(item) - } - override fun handleQRCodeScanned(hexEncodedPublicKey: String) { createPrivateChatIfPossible(hexEncodedPublicKey) } @@ -106,12 +112,12 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC private fun createPrivateChat(hexEncodedPublicKey: String) { val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) - val intent = Intent(this, ConversationActivity::class.java) - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA)) intent.setDataAndType(getIntent().data, getIntent().type) val existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient) - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, existingThread) + intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, DistributionTypes.DEFAULT) startActivity(intent) finish() @@ -122,6 +128,8 @@ class CreatePrivateChatActivity : PassphraseRequiredActionBarActivity(), ScanQRC // region Adapter private class CreatePrivateChatActivityAdapter(val activity: CreatePrivateChatActivity) : FragmentPagerAdapter(activity.supportFragmentManager) { val enterPublicKeyFragment = EnterPublicKeyFragment() + var isKeyboardShowing = false + set(value) { field = value; enterPublicKeyFragment.isKeyboardShowing = isKeyboardShowing } override fun getCount(): Int { return 2 @@ -152,6 +160,8 @@ private class CreatePrivateChatActivityAdapter(val activity: CreatePrivateChatAc // region Enter Public Key Fragment class EnterPublicKeyFragment : Fragment() { + var isKeyboardShowing = false + set(value) { field = value; handleIsKeyboardShowingChanged() } private val hexEncodedPublicKey: String get() { @@ -182,6 +192,10 @@ class EnterPublicKeyFragment : Fragment() { createPrivateChatButton.setOnClickListener { createPrivateChatIfPossible() } } + private fun handleIsKeyboardShowingChanged() { + optionalContentContainer.isVisible = !isKeyboardShowing + } + private fun copyPublicKey() { val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey) @@ -197,9 +211,10 @@ class EnterPublicKeyFragment : Fragment() { startActivity(intent) } - fun createPrivateChatIfPossible() { - val hexEncodedPublicKey = publicKeyEditText.text?.trim().toString() ?: "" - (requireActivity() as CreatePrivateChatActivity).createPrivateChatIfPossible(hexEncodedPublicKey) + private fun createPrivateChatIfPossible() { + val hexEncodedPublicKey = publicKeyEditText.text?.trim().toString() + val activity = requireActivity() as CreatePrivateChatActivity + activity.createPrivateChatIfPossible(hexEncodedPublicKey) } } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt index 54cf2f8d7c..474d8036de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/HomeActivity.kt @@ -38,6 +38,7 @@ import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.loki.api.OpenGroupManager @@ -342,13 +343,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis } private fun openConversation(thread: ThreadRecord) { - val intent = Intent(this, ConversationActivity::class.java) - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, thread.recipient.address) - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, thread.threadId) - intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, thread.distributionType) - intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis()) - intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, thread.lastSeen) - intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1) + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, thread.threadId) push(intent) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt index 78b7ced770..dc038cc0cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/JoinPublicChatActivity.kt @@ -33,6 +33,7 @@ import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.loki.api.OpenGroupManager import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragment @@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragmentDelega import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol import org.thoughtcrime.securesms.loki.viewmodel.DefaultGroupsViewModel import org.thoughtcrime.securesms.loki.viewmodel.State +import java.util.* class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { private val adapter = JoinPublicChatActivityAdapter(this) @@ -126,10 +128,10 @@ class JoinPublicChatActivity : PassphraseRequiredActionBarActivity(), ScanQRCode // region Convenience private fun openConversationActivity(context: Context, threadId: Long, recipient: Recipient) { - val intent = Intent(context, ConversationActivity::class.java) - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId) + val intent = Intent(context, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId) intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, DistributionTypes.DEFAULT) - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) context.startActivity(intent) } // endregion @@ -179,6 +181,7 @@ class EnterChatURLFragment : Fragment() { joinPublicChatButton.setOnClickListener { joinPublicChatIfPossible() } viewModel.defaultRooms.observe(viewLifecycleOwner) { state -> defaultRoomsContainer.isVisible = state is State.Success + defaultRoomsLoaderContainer.isVisible = state is State.Loading defaultRoomsLoader.isVisible = state is State.Loading when (state) { State.Loading -> { @@ -210,7 +213,6 @@ class EnterChatURLFragment : Fragment() { chip.setOnClickListener { (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(defaultGroup.joinURL) } - defaultRoomsGridLayout.addView(chip) } if ((groups.size and 1) != 0) { // This checks that the number of rooms is even @@ -222,7 +224,7 @@ class EnterChatURLFragment : Fragment() { private fun joinPublicChatIfPossible() { val inputMethodManager = requireContext().getSystemService(BaseActionBarActivity.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.hideSoftInputFromWindow(chatURLEditText.windowToken, 0) - val chatURL = chatURLEditText.text.trim().toString().toLowerCase() + val chatURL = chatURLEditText.text.trim().toString().toLowerCase(Locale.US) (requireActivity() as JoinPublicChatActivity).joinPublicChatIfPossible(chatURL) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/OpenGroupGuidelinesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/OpenGroupGuidelinesActivity.kt index 316f07ea39..9c714b6eff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/OpenGroupGuidelinesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/OpenGroupGuidelinesActivity.kt @@ -7,7 +7,6 @@ import org.thoughtcrime.securesms.BaseActionBarActivity class OpenGroupGuidelinesActivity : BaseActionBarActivity() { - // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_open_group_guidelines) @@ -49,5 +48,4 @@ class OpenGroupGuidelinesActivity : BaseActionBarActivity() { Trust only those with an admin crown in chat. No admin will ever DM you first. No admin will ever message you for Oxen coins. """.trimIndent() } - // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/QRCodeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/QRCodeActivity.kt index dd6bf3420e..96d175ef99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/activities/QRCodeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/activities/QRCodeActivity.kt @@ -26,6 +26,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.util.FileProviderUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import java.io.File import java.io.FileOutputStream @@ -53,12 +54,12 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity(), ScanQRCodeWrapperF fun createPrivateChatIfPossible(hexEncodedPublicKey: String) { if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.invalid_session_id, Toast.LENGTH_SHORT).show() } val recipient = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), false) - val intent = Intent(this, ConversationActivity::class.java) - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + val intent = Intent(this, ConversationActivityV2::class.java) + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address) intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA)) intent.setDataAndType(getIntent().data, getIntent().type) val existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient) - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, existingThread) + intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread) intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, DistributionTypes.DEFAULT) startActivity(intent) finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/ClearAllDataDialog.kt index cb1bf22566..9db4533c9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/ClearAllDataDialog.kt @@ -12,18 +12,16 @@ import network.loki.messenger.R import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol import org.session.libsession.utilities.KeyPairUtilities +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities -class ClearAllDataDialog : DialogFragment() { +class ClearAllDataDialog : BaseDialog() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireContext()) + override fun setContentView(builder: AlertDialog.Builder) { val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_clear_all_data, null) contentView.cancelButton.setOnClickListener { dismiss() } contentView.clearAllDataButton.setOnClickListener { clearAllData() } builder.setView(contentView) - val result = builder.create() - result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - return result } private fun clearAllData() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/SeedDialog.kt index 2bd9595b22..ab847ec2c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/SeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/dialogs/SeedDialog.kt @@ -1,24 +1,20 @@ package org.thoughtcrime.securesms.loki.dialogs -import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.Bundle import android.view.LayoutInflater import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment import kotlinx.android.synthetic.main.dialog_seed.view.* import network.loki.messenger.R import org.session.libsession.utilities.IdentityKeyUtil import org.thoughtcrime.securesms.loki.utilities.MnemonicUtilities import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog -class SeedDialog : DialogFragment() { +class SeedDialog : BaseDialog() { private val seed by lazy { var hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) @@ -31,16 +27,12 @@ class SeedDialog : DialogFragment() { MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireContext()) + override fun setContentView(builder: AlertDialog.Builder) { val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_seed, null) contentView.seedTextView.text = seed contentView.cancelButton.setOnClickListener { dismiss() } contentView.copyButton.setOnClickListener { copySeed() } builder.setView(contentView) - val result = builder.create() - result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - return result } private fun copySeed() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/fragments/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/fragments/ScanQRCodeWrapperFragment.kt index 92eae8378a..52414bef9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/fragments/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/fragments/ScanQRCodeWrapperFragment.kt @@ -31,7 +31,7 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) - enabled = isVisibleToUser + enabled = isVisibleToUser } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -87,5 +87,6 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg } interface ScanQRCodeWrapperFragmentDelegate { + fun handleQRCodeScanned(string: String) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ActivityUtilities.kt index 6fd9250e0d..4986a1ce36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ActivityUtilities.kt @@ -1,6 +1,10 @@ package org.thoughtcrime.securesms.loki.utilities +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context import android.content.Intent +import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar @@ -52,4 +56,13 @@ fun AppCompatActivity.show(intent: Intent, isForResult: Boolean = false) { startActivity(intent) } overridePendingTransition(R.anim.slide_from_bottom, R.anim.fade_scale_out) +} + +interface ActivityDispatcher { + companion object { + const val SERVICE = "ActivityDispatcher_SERVICE" + @SuppressLint("WrongConstant") + fun get(context: Context) = context.getSystemService(SERVICE) as? ActivityDispatcher + } + fun dispatchIntent(body: (Context)->Intent?) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/GeneralUtilities.kt index 6bb7a7a9e0..798f937d9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/GeneralUtilities.kt @@ -14,6 +14,19 @@ fun Resources.getColorWithID(@ColorRes id: Int, theme: Resources.Theme?): Int { } fun toPx(dp: Int, resources: Resources): Int { - val scale = resources.displayMetrics.density - return (dp * scale).roundToInt() + return toPx(dp.toFloat(), resources).roundToInt() +} + +fun toPx(dp: Float, resources: Resources): Float { + val scale = resources.displayMetrics.density + return (dp * scale) +} + +fun toDp(px: Int, resources: Resources): Int { + return toDp(px.toFloat(), resources).roundToInt() +} + +fun toDp(px: Float, resources: Resources): Float { + val scale = resources.displayMetrics.density + return (px / scale) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionUtilities.kt index 45fda301d8..d54d19f0ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/MentionUtilities.kt @@ -7,6 +7,7 @@ import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range +import androidx.core.content.res.ResourcesCompat import network.loki.messenger.R import nl.komponents.kovenant.combine.Tuple2 import org.session.libsession.messaging.contacts.Contact @@ -23,7 +24,7 @@ object MentionUtilities { @JvmStatic fun highlightMentions(text: CharSequence, isOutgoingMessage: Boolean, threadID: Long, context: Context): SpannableString { - var text = text + @Suppress("NAME_SHADOWING") var text = text val threadDB = DatabaseFactory.getThreadDatabase(context) val isOpenGroup = threadDB.getRecipientForThreadId(threadID)?.isOpenGroupRecipient ?: false val pattern = Pattern.compile("@[0-9a-fA-F]*") @@ -38,7 +39,7 @@ object MentionUtilities { TextSecurePreferences.getProfileName(context) } else { val contact = DatabaseFactory.getSessionContactDatabase(context).getContactWithSessionID(publicKey) - val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR + @Suppress("NAME_SHADOWING") val context = if (isOpenGroup) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR contact?.displayName(context) } if (userDisplayName != null) { @@ -54,10 +55,15 @@ object MentionUtilities { } } val result = SpannableString(text) + val isLightMode = UiModeUtilities.isDayUiMode(context) for (mention in mentions) { - val isLightMode = UiModeUtilities.isDayUiMode(context) - val colorID = if (isLightMode && isOutgoingMessage) R.color.black else R.color.accent - result.setSpan(ForegroundColorSpan(context.resources.getColorWithID(colorID, context.theme)), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val colorID = if (isOutgoingMessage) { + if (isLightMode) R.color.white else R.color.black + } else { + R.color.accent + } + val color = ResourcesCompat.getColor(context.resources, colorID, context.theme) + result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } return result diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ViewUtilities.kt index c9d135ba24..67a137bc48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/utilities/ViewUtilities.kt @@ -21,9 +21,13 @@ val View.hitRect: Rect } fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) { - val layoutParams = this.layoutParams val startSize = resources.getDimension(startSizeID) val endSize = resources.getDimension(endSizeID) + animateSizeChange(startSize, endSize) +} + +fun View.animateSizeChange(startSize: Float, endSize: Float, animationDuration: Long = 250) { + val layoutParams = this.layoutParams val animation = ValueAnimator.ofObject(FloatEvaluator(), startSize, endSize) animation.duration = animationDuration animation.addUpdateListener { animator -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/ConversationView.kt index 7ef90c0129..893c73019e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/ConversationView.kt @@ -1,17 +1,19 @@ package org.thoughtcrime.securesms.loki.views import android.content.Context +import android.content.res.Resources import android.graphics.Typeface import android.util.AttributeSet +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.view_conversation.view.* import network.loki.messenger.R -import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.SSKEnvironment -import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.loki.utilities.MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded import org.thoughtcrime.securesms.loki.utilities.MentionUtilities.highlightMentions @@ -20,23 +22,17 @@ import org.thoughtcrime.securesms.util.DateUtils import java.util.* class ConversationView : LinearLayout { + private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null // region Lifecycle - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { + private fun initialize() { LayoutInflater.from(context).inflate(R.layout.view_conversation, this) + layoutParams = RecyclerView.LayoutParams(screenWidth, RecyclerView.LayoutParams.WRAP_CONTENT) } // endregion @@ -44,23 +40,30 @@ class ConversationView : LinearLayout { fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) { this.thread = thread populateUserPublicKeyCacheIfNeeded(thread.threadId, context) // FIXME: This is a bad place to do this + val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { accentView.setBackgroundResource(R.color.destructive) accentView.visibility = View.VISIBLE } else { accentView.setBackgroundResource(R.color.accent) - accentView.visibility = if (thread.unreadCount > 0) View.VISIBLE else View.INVISIBLE + accentView.visibility = if (unreadCount > 0) View.VISIBLE else View.INVISIBLE } + val formattedUnreadCount = if (unreadCount < 100) unreadCount.toString() else "99+" + unreadCountTextView.text = formattedUnreadCount + val textSize = if (unreadCount < 100) 12.0f else 9.0f + unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) + unreadCountTextView.setTypeface(Typeface.DEFAULT, if (unreadCount < 100) Typeface.BOLD else Typeface.NORMAL) + unreadCountIndicator.isVisible = (unreadCount != 0) profilePictureView.glide = glide profilePictureView.update(thread.recipient, thread.threadId) val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() - btnGroupNameDisplay.text = senderDisplayName + conversationViewDisplayNameTextView.text = senderDisplayName timestampTextView.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), thread.date) muteIndicatorImageView.visibility = if (thread.recipient.isMuted) VISIBLE else GONE val rawSnippet = thread.getDisplayBody(context) val snippet = highlightMentions(rawSnippet, thread.threadId, context) snippetTextView.text = snippet - snippetTextView.typeface = if (thread.unreadCount > 0) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + snippetTextView.typeface = if (unreadCount > 0) Typeface.DEFAULT_BOLD else Typeface.DEFAULT snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE if (isTyping) { typingIndicatorView.startAnimation() @@ -71,9 +74,13 @@ class ConversationView : LinearLayout { statusIndicatorImageView.visibility = View.VISIBLE when { !thread.isOutgoing -> statusIndicatorImageView.visibility = View.GONE - thread.isFailed -> statusIndicatorImageView.setImageResource(R.drawable.ic_error) + thread.isFailed -> { + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_error)?.mutate() + drawable?.setTint(ContextCompat.getColor(context,R.color.destructive)) + statusIndicatorImageView.setImageDrawable(drawable) + } thread.isPending -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot) - thread.isRemoteRead -> statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) + thread.isRead -> statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/GlowView.kt index e48a67fbc9..c2619a6044 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/GlowView.kt @@ -6,13 +6,14 @@ import android.content.Context import android.graphics.* import android.util.AttributeSet import android.view.View -import android.view.ViewOutlineProvider import android.widget.LinearLayout +import android.widget.RelativeLayout import androidx.annotation.ColorInt import androidx.annotation.ColorRes import network.loki.messenger.R import org.thoughtcrime.securesms.loki.utilities.getColorWithID import org.thoughtcrime.securesms.loki.utilities.toPx +import kotlin.math.roundToInt interface GlowView { var mainColor: Int @@ -155,4 +156,50 @@ class PathDotView : View, GlowView { super.onDraw(c) } // endregion -} \ No newline at end of file +} + +class InputBarButtonImageViewContainer : RelativeLayout, GlowView { + @ColorInt override var mainColor: Int = 0 + set(newValue) { field = newValue; fillPaint.color = newValue } + @ColorInt var strokeColor: Int = 0 + set(newValue) { field = newValue; strokePaint.color = newValue } + @ColorInt override var sessionShadowColor: Int = 0 // Unused + + private val fillPaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + private val strokePaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.STROKE + result.isAntiAlias = true + result.strokeWidth = 1.0f + result.alpha = (255 * 0.2f).roundToInt() + result + } + + // region Lifecycle + constructor(context: Context) : super(context) { } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { } + + init { + setWillNotDraw(false) + } + // endregion + + // region Updating + override fun onDraw(c: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + c.drawCircle(w / 2, h / 2, w / 2, fillPaint) + if (strokeColor != 0) { + c.drawCircle(w / 2, h / 2, w / 2, strokePaint) + } + super.onDraw(c) + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt index d355597bf9..e8e1c4f9a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/MentionCandidateView.kt @@ -30,7 +30,7 @@ class MentionCandidateView(context: Context, attrs: AttributeSet?, defStyleAttr: } private fun update() { - btnGroupNameDisplay.text = mentionCandidate.displayName + mentionCandidateNameTextView.text = mentionCandidate.displayName profilePictureView.publicKey = mentionCandidate.publicKey profilePictureView.displayName = mentionCandidate.displayName profilePictureView.additionalPublicKey = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/NewConversationButtonSetView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/NewConversationButtonSetView.kt index e4578e5fa9..a418369c90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/NewConversationButtonSetView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/NewConversationButtonSetView.kt @@ -1,19 +1,15 @@ package org.thoughtcrime.securesms.loki.views -import android.animation.ArgbEvaluator import android.animation.FloatEvaluator import android.animation.PointFEvaluator import android.animation.ValueAnimator import android.content.Context -import android.content.Context.VIBRATOR_SERVICE import android.content.res.ColorStateList import android.graphics.PointF import android.os.Build -import android.os.VibrationEffect -import android.os.VibrationEffect.DEFAULT_AMPLITUDE -import android.os.Vibrator import android.util.AttributeSet import android.view.Gravity +import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.widget.ImageView import android.widget.RelativeLayout @@ -162,6 +158,7 @@ class NewConversationButtonSetView : RelativeLayout { private fun setUpViewHierarchy() { disableClipping() + isHapticFeedbackEnabled = true // Set up session button addView(sessionButton) sessionButton.alpha = 0.0f @@ -206,11 +203,10 @@ class NewConversationButtonSetView : RelativeLayout { isExpanded = true expand() } - val vibrator = context.getSystemService(VIBRATOR_SERVICE) as Vibrator - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(50, DEFAULT_AMPLITUDE)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } else { - vibrator.vibrate(50) + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } } MotionEvent.ACTION_MOVE -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt index 3482088dd9..b07675f5ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/loki/views/ProfilePictureView.kt @@ -31,23 +31,12 @@ class ProfilePictureView : RelativeLayout { private val profilePicturesCache = mutableMapOf() // region Lifecycle - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } + constructor(context: Context) : super(context) { initialize() } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { initialize() } - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { + private fun initialize() { val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater val contentView = inflater.inflate(R.layout.view_profile_picture, null) addView(contentView) diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java index d3c683dd46..adb8a921b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java @@ -2,36 +2,20 @@ package org.thoughtcrime.securesms.longmessage; import android.content.Context; import android.content.Intent; -import android.graphics.PorterDuff; import android.os.Bundle; -import android.text.SpannableString; import android.text.method.LinkMovementMethod; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.TypedValue; import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProvider; -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; -import org.thoughtcrime.securesms.components.ConversationItemFooter; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; - import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ThemeUtil; import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.Stub; - -import java.util.Locale; +import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.loki.utilities.MentionUtilities; import network.loki.messenger.R; @@ -43,8 +27,7 @@ public class LongMessageActivity extends PassphraseRequiredActionBarActivity { private static final int MAX_DISPLAY_LENGTH = 64 * 1024; - private Stub sentBubble; - private Stub receivedBubble; + private TextView textBody; private LongMessageViewModel viewModel; @@ -60,9 +43,7 @@ public class LongMessageActivity extends PassphraseRequiredActionBarActivity { protected void onCreate(Bundle savedInstanceState, boolean ready) { super.onCreate(savedInstanceState, ready); setContentView(R.layout.longmessage_activity); - - sentBubble = new Stub<>(findViewById(R.id.longmessage_sent_stub)); - receivedBubble = new Stub<>(findViewById(R.id.longmessage_received_stub)); + textBody = findViewById(R.id.longmessage_text); initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1), getIntent().getBooleanExtra(KEY_IS_MMS, false)); } @@ -93,36 +74,19 @@ public class LongMessageActivity extends PassphraseRequiredActionBarActivity { return; } - if (message.get().getMessageRecord().isOutgoing()) { getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_your_message)); } else { Recipient recipient = message.get().getMessageRecord().getRecipient(); - String name = Util.getFirstNonEmpty(recipient.getName(), recipient.getProfileName(), recipient.getAddress().serialize()) ; + String name = Util.getFirstNonEmpty(recipient.getName(), recipient.getProfileName(), recipient.getAddress().serialize()); getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_message_from_s, name)); } - ViewGroup bubble; + String trimmedBody = getTrimmedBody(message.get().getFullBody()); + String mentionBody = MentionUtilities.highlightMentions(trimmedBody, message.get().getMessageRecord().getThreadId(), this); - if (message.get().getMessageRecord().isOutgoing()) { - bubble = sentBubble.get(); - bubble.getBackground().setColorFilter(ThemeUtil.getThemedColor(this, R.attr.message_sent_background_color), PorterDuff.Mode.MULTIPLY); - } else { - bubble = receivedBubble.get(); - bubble.getBackground().setColorFilter(ThemeUtil.getThemedColor(this, R.attr.message_received_background_color), PorterDuff.Mode.MULTIPLY); - } - - TextView text = bubble.findViewById(R.id.longmessage_text); - ConversationItemFooter footer = bubble.findViewById(R.id.longmessage_footer); - - String trimmedBody = getTrimmedBody(message.get().getFullBody()); - SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody)); - - bubble.setVisibility(View.VISIBLE); - text.setText(styledBody); - text.setMovementMethod(LinkMovementMethod.getInstance()); - text.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(this)); - footer.setMessageRecord(message.get().getMessageRecord(), Locale.getDefault()); + textBody.setText(mentionBody); + textBody.setMovementMethod(LinkMovementMethod.getInstance()); }); } @@ -131,15 +95,4 @@ public class LongMessageActivity extends PassphraseRequiredActionBarActivity { : text.substring(0, MAX_DISPLAY_LENGTH); } - private SpannableString linkifyMessageBody(SpannableString messageBody) { - int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS; - boolean hasLinks = Linkify.addLinks(messageBody, linkPattern); - - if (hasLinks) { - Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class)) - .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) - .forEach(messageBody::removeSpan); - } - return messageBody; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java index 7767d0d7ea..dd27c42502 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -10,7 +10,7 @@ import android.view.ViewGroup; import network.loki.messenger.R; -import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.util.StableIdGenerator; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index 9a1885ab90..3d45e6a6e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -31,7 +31,6 @@ import org.session.libsession.utilities.MediaTypes; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.util.ResUtil; - public class AudioSlide extends Slide { public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 532c953152..a0c1046e07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -49,6 +49,7 @@ import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; @@ -115,9 +116,9 @@ public class DefaultMessageNotifier implements MessageNotifier { if (visibleThread == threadId) { sendInThreadNotification(context, recipient); } else { - Intent intent = new Intent(context, ConversationActivity.class); - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress()); - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); + Intent intent = new Intent(context, ConversationActivityV2.class); + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.getAddress()); + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); intent.setData((Uri.parse("custom://" + System.currentTimeMillis()))); FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 3df423a6de..c12a3f196b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -81,7 +81,6 @@ public class MarkReadReceiver extends BroadcastReceiver { for (Address address : addressMap.keySet()) { List timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - // Loki - Check whether we want to send a read receipt to this user if (!SessionMetaProtocol.shouldSendReadReceipt(address)) { continue; } ReadReceipt readReceipt = new ReadReceipt(timestamps); readReceipt.setSentTimestamp(System.currentTimeMillis()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java index e92f62aa44..c2041a84fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -9,6 +9,7 @@ import androidx.annotation.Nullable; import androidx.core.app.TaskStackBuilder; import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.mms.SlideDeck; import org.session.libsession.utilities.recipients.Recipient; @@ -67,11 +68,11 @@ public class NotificationItem { } public PendingIntent getPendingIntent(Context context) { - Intent intent = new Intent(context, ConversationActivity.class); + Intent intent = new Intent(context, ConversationActivityV2.class); Recipient notifyRecipients = threadRecipient != null ? threadRecipient : conversationRecipient; - if (notifyRecipients != null) intent.putExtra(ConversationActivity.ADDRESS_EXTRA, notifyRecipients.getAddress()); + if (notifyRecipients != null) intent.putExtra(ConversationActivityV2.ADDRESS, notifyRecipients.getAddress()); - intent.putExtra("thread_id", threadId); + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); return TaskStackBuilder.create(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java index e516916de4..409b2bb24a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -103,7 +103,6 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment TextSecurePreferences.setScreenLockTimeout(getContext(), 0); } else { long timeoutSeconds = TimeUnit.MILLISECONDS.toSeconds(duration); -// long timeoutSeconds = Math.max(TimeUnit.MILLISECONDS.toSeconds(duration), 60); TextSecurePreferences.setScreenLockTimeout(getContext(), timeoutSeconds); } @@ -117,7 +116,6 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; return true; } } @@ -138,21 +136,6 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - - if (enabled) { - AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); - builder.setTitle("Enable Link Previews?"); - builder.setMessage("You will not have full metadata protection when sending or receiving link previews."); - builder.setPositiveButton("OK", (dialog, which) -> dialog.dismiss()); - builder.setNegativeButton("Cancel", (dialog, which) -> { - TextSecurePreferences.setLinkPreviewsEnabled(requireContext(), false); - ((SwitchPreferenceCompat)AppProtectionPreferenceFragment.this.findPreference(TextSecurePreferences.LINK_PREVIEWS)).setChecked(false); - dialog.dismiss(); - }); - builder.create().show(); - } - return true; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt index 2fc8c1cbaf..62002c88b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId class ReadReceiptManager: SSKEnvironment.ReadReceiptManagerProtocol { + override fun processReadReceipts(context: Context, fromRecipientId: String, sentTimestamps: List, readTimestamp: Long) { if (TextSecurePreferences.isReadReceiptsEnabled(context)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 418de713cb..e8dd7cd589 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -14,6 +14,7 @@ import android.widget.Toast; import org.thoughtcrime.securesms.conversation.ConversationActivity; import network.loki.messenger.R; +import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.session.libsession.utilities.recipients.Recipient; @@ -32,9 +33,9 @@ public class CommunicationActions { @Override protected void onPostExecute(Long threadId) { - Intent intent = new Intent(context, ConversationActivity.class); - intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress()); - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); + Intent intent = new Intent(context, ConversationActivityV2.class); + intent.putExtra(ConversationActivityV2.ADDRESS, recipient.getAddress()); + intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis()); if (!TextUtils.isEmpty(text)) { diff --git a/app/src/main/res/drawable-mdpi/ic_plus_24.png b/app/src/main/res/drawable-mdpi/ic_plus_24.png new file mode 100644 index 0000000000..5a11ea9a1d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_plus_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_plus_24.png b/app/src/main/res/drawable-xhdpi/ic_plus_24.png new file mode 100644 index 0000000000..1421a562e9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_plus_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus_24.png b/app/src/main/res/drawable-xxhdpi/ic_plus_24.png new file mode 100644 index 0000000000..d63c48c714 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_plus_24.png differ diff --git a/app/src/main/res/drawable/circle_tintable.xml b/app/src/main/res/drawable/circle_tintable.xml index 6c5c360635..60c36468e4 100644 --- a/app/src/main/res/drawable/circle_tintable.xml +++ b/app/src/main/res/drawable/circle_tintable.xml @@ -1,5 +1,6 @@ - + diff --git a/app/src/main/res/drawable/conversation_back_button_background.xml b/app/src/main/res/drawable/conversation_back_button_background.xml new file mode 100644 index 0000000000..f259e83e74 --- /dev/null +++ b/app/src/main/res/drawable/conversation_back_button_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/default_dialog_background.xml b/app/src/main/res/drawable/default_dialog_background.xml index fff764ebd5..4cd206aca8 100644 --- a/app/src/main/res/drawable/default_dialog_background.xml +++ b/app/src/main/res/drawable/default_dialog_background.xml @@ -7,5 +7,4 @@ - \ No newline at end of file diff --git a/app/src/main/res/drawable/default_dialog_background_inset.xml b/app/src/main/res/drawable/default_dialog_background_inset.xml index b67cfb0b73..0ff315ebd3 100644 --- a/app/src/main/res/drawable/default_dialog_background_inset.xml +++ b/app/src/main/res/drawable/default_dialog_background_inset.xml @@ -2,6 +2,5 @@ + android:inset="@dimen/medium_spacing"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000000..bbf9960747 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000000..56b2c2168c --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/input_bar_button_background.xml b/app/src/main/res/drawable/input_bar_button_background.xml new file mode 100644 index 0000000000..4de519558a --- /dev/null +++ b/app/src/main/res/drawable/input_bar_button_background.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mention_candidate_view_background.xml b/app/src/main/res/drawable/mention_candidate_view_background.xml index af9549d111..1b30b3e72e 100644 --- a/app/src/main/res/drawable/mention_candidate_view_background.xml +++ b/app/src/main/res/drawable/mention_candidate_view_background.xml @@ -1,9 +1,9 @@ + android:color="@color/mention_candidates_view_background_ripple"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/message_bubble_background.xml b/app/src/main/res/drawable/message_bubble_background.xml index cda641b1da..9c4347ae6e 100644 --- a/app/src/main/res/drawable/message_bubble_background.xml +++ b/app/src/main/res/drawable/message_bubble_background.xml @@ -4,7 +4,6 @@ - diff --git a/app/src/main/res/drawable/message_bubble_background_received_alone.xml b/app/src/main/res/drawable/message_bubble_background_received_alone.xml index cda641b1da..5e657c9793 100644 --- a/app/src/main/res/drawable/message_bubble_background_received_alone.xml +++ b/app/src/main/res/drawable/message_bubble_background_received_alone.xml @@ -1,10 +1,7 @@ - - + diff --git a/app/src/main/res/drawable/message_bubble_background_received_end.xml b/app/src/main/res/drawable/message_bubble_background_received_end.xml index 3e4e2c0562..09f5d7d6df 100644 --- a/app/src/main/res/drawable/message_bubble_background_received_end.xml +++ b/app/src/main/res/drawable/message_bubble_background_received_end.xml @@ -1,10 +1,7 @@ - - + - - + - - + - - + diff --git a/app/src/main/res/drawable/message_bubble_background_sent_end.xml b/app/src/main/res/drawable/message_bubble_background_sent_end.xml index 5bc4597c03..81325f1ad2 100644 --- a/app/src/main/res/drawable/message_bubble_background_sent_end.xml +++ b/app/src/main/res/drawable/message_bubble_background_sent_end.xml @@ -1,10 +1,7 @@ - - + - - + - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/view_quote_attachment_preview_background.xml b/app/src/main/res/drawable/view_quote_attachment_preview_background.xml new file mode 100644 index 0000000000..2044a98c1a --- /dev/null +++ b/app/src/main/res/drawable/view_quote_attachment_preview_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/view_scroll_to_bottom_button_background.xml b/app/src/main/res/drawable/view_scroll_to_bottom_button_background.xml new file mode 100644 index 0000000000..512b3861a7 --- /dev/null +++ b/app/src/main/res/drawable/view_scroll_to_bottom_button_background.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/view_voice_message_duration_text_view_background.xml b/app/src/main/res/drawable/view_voice_message_duration_text_view_background.xml new file mode 100644 index 0000000000..78871b9e1e --- /dev/null +++ b/app/src/main/res/drawable/view_voice_message_duration_text_view_background.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw400dp/activity_display_name.xml b/app/src/main/res/layout-sw400dp/activity_display_name.xml index ecb0a35967..fe00d541a2 100644 --- a/app/src/main/res/layout-sw400dp/activity_display_name.xml +++ b/app/src/main/res/layout-sw400dp/activity_display_name.xml @@ -34,12 +34,13 @@ style="@style/SessionEditText" android:id="@+id/displayNameEditText" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="28dp" - android:paddingBottom="28dp" + android:layout_height="64dp" android:layout_marginLeft="@dimen/very_large_spacing" android:layout_marginTop="12dp" android:layout_marginRight="@dimen/very_large_spacing" + android:paddingTop="0dp" + android:paddingBottom="0dp" + android:gravity="center_vertical" android:inputType="textCapWords" android:hint="@string/activity_display_name_edit_text_hint" /> diff --git a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml b/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml index a011b2bf4b..a689ad6258 100644 --- a/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml +++ b/app/src/main/res/layout-sw400dp/fragment_enter_chat_url.xml @@ -1,5 +1,7 @@ - - + + + + + @@ -41,56 +41,65 @@ android:alpha="0.6" android:textAlignment="center" android:text="@string/fragment_enter_public_key_explanation" /> - - - - + android:orientation="vertical"> -