Add send/recv/render support for text stories.

fork-5.53.8
Alex Hart 2022-03-09 13:11:56 -04:00 zatwierdzone przez Cody Henthorne
rodzic 3a2e8b9b19
commit ff8d7fa6c2
40 zmienionych plików z 963 dodań i 93 usunięć

Wyświetl plik

@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ThemeUtil;
@ -209,7 +210,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
this.author.observeForever(this);
setQuoteAuthor(author);
setQuoteText(body, attachments);
setQuoteAttachment(glideRequests, attachments);
setQuoteAttachment(glideRequests, body, attachments);
setQuoteMissingFooter(originalMissing);
if (Build.VERSION.SDK_INT < 21 && messageType == MessageType.INCOMING && chatColors != null) {
@ -264,7 +265,9 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
boolean isTextStory = !attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY;
if (!isTextStory && (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide())) {
bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body);
mediaDescriptionText.setVisibility(GONE);
@ -274,6 +277,11 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
if (isTextStory) {
// TODO [alex] -- Media description.
return;
}
Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
@ -305,7 +313,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck) {
if (!attachments.containsMediaSlide() && messageType == MessageType.STORY_REPLY) {
StoryTextPostModel model = StoryTextPostModel.parseFrom(body.toString());
attachmentVideoOverlayView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
thumbnailView.setVisibility(VISIBLE);
glideRequests.load(model)
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
return;
}
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);

Wyświetl plik

@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@ -391,6 +392,31 @@ public class ThumbnailView extends FrameLayout {
return future;
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull StoryTextPostModel model, int width, int height) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest request = glideRequests.load(model)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
if (width > 0 && height > 0) {
request = request.override(width, height);
}
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
request.into(new GlideDrawableListeningTarget(image, future));
blurhash.setImageDrawable(null);
return future;
}
public void setThumbnailClickListener(SlideClickListener listener) {
this.thumbnailClickListener = listener;
}

Wyświetl plik

@ -5,6 +5,7 @@ import androidx.core.util.Consumer
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
@ -30,7 +31,7 @@ class MultiselectForwardRepository(context: Context) {
fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey>, consumer: Consumer<List<IdentityRecord>>) {
SignalExecutors.BOUNDED.execute {
val recipients: List<Recipient> = contactSearchKeys
.filterIsInstance<ContactSearchKey.KnownRecipient>()
.filterIsInstance<RecipientSearchKey>()
.map { Recipient.resolved(it.recipientId) }
val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients)

Wyświetl plik

@ -17,11 +17,33 @@ enum class StoryType(val code: Int) {
/**
* User cannot send replies to this story.
*/
STORY_WITHOUT_REPLIES(2);
STORY_WITHOUT_REPLIES(2),
val isStory get() = this == STORY_WITH_REPLIES || this == STORY_WITHOUT_REPLIES
/**
* Text story that allows replies
*/
TEXT_STORY_WITH_REPLIES(3),
val isStoryWithReplies get() = this == STORY_WITH_REPLIES
/**
* Text story that does not allow replies
*/
TEXT_STORY_WITHOUT_REPLIES(4);
val isStory get() = this != NONE
val isStoryWithReplies get() = this == STORY_WITH_REPLIES || this == TEXT_STORY_WITH_REPLIES
val isTextStory get() = this == TEXT_STORY_WITHOUT_REPLIES || this == TEXT_STORY_WITH_REPLIES
fun toTextStoryType(): StoryType {
return when (this) {
NONE -> NONE
STORY_WITH_REPLIES -> TEXT_STORY_WITH_REPLIES
STORY_WITHOUT_REPLIES -> TEXT_STORY_WITHOUT_REPLIES
TEXT_STORY_WITH_REPLIES -> TEXT_STORY_WITH_REPLIES
TEXT_STORY_WITHOUT_REPLIES -> TEXT_STORY_WITHOUT_REPLIES
}
}
companion object {
@JvmStatic
@ -29,8 +51,20 @@ enum class StoryType(val code: Int) {
return when (code) {
1 -> STORY_WITH_REPLIES
2 -> STORY_WITHOUT_REPLIES
3 -> TEXT_STORY_WITH_REPLIES
4 -> TEXT_STORY_WITHOUT_REPLIES
else -> NONE
}
}
@JvmStatic
fun withReplies(isTextStory: Boolean): StoryType {
return if (isTextStory) TEXT_STORY_WITH_REPLIES else STORY_WITH_REPLIES
}
@JvmStatic
fun withoutReplies(isTextStory: Boolean): StoryType {
return if (isTextStory) TEXT_STORY_WITHOUT_REPLIES else STORY_WITHOUT_REPLIES
}
}
}

Wyświetl plik

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.fonts
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
/**
* Describes which font the user wishes to render content in.
@ -13,4 +14,18 @@ enum class TextFont(@DrawableRes val icon: Int, val fallbackFamily: String, val
SERIF(R.drawable.ic_font_serif, "serif", Typeface.NORMAL, false),
SCRIPT(R.drawable.ic_font_script, "serif", Typeface.BOLD, false),
CONDENSED(R.drawable.ic_font_condensed, "sans-serif", Typeface.BOLD, true);
companion object {
fun fromStyle(style: StoryTextPost.Style): TextFont {
return when (style) {
StoryTextPost.Style.DEFAULT -> REGULAR
StoryTextPost.Style.REGULAR -> REGULAR
StoryTextPost.Style.BOLD -> BOLD
StoryTextPost.Style.SERIF -> SERIF
StoryTextPost.Style.SCRIPT -> SCRIPT
StoryTextPost.Style.CONDENSED -> CONDENSED
StoryTextPost.Style.UNRECOGNIZED -> REGULAR
}
}
}
}

Wyświetl plik

@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.messages.GroupSendUtil;
import org.thoughtcrime.securesms.messages.StorySendUtil;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -59,11 +60,11 @@ public final class PushDistributionListSendJob extends PushSendJob {
public PushDistributionListSendJob(long messageId, @NonNull RecipientId destination, boolean hasMedia) {
this(new Parameters.Builder()
.setQueue(destination.toQueueKey(hasMedia))
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
.setQueue(destination.toQueueKey(hasMedia))
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
messageId);
}
@ -147,7 +148,7 @@ public final class PushDistributionListSendJob extends PushSendJob {
List<Recipient> target;
if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList();
else target = Stream.of(getFullRecipients(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList();
else target = Stream.of(getFullRecipients(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList();
List<SendMessageResult> results = deliver(message, target);
Log.i(TAG, JobLogger.format(this, "Finished send."));
@ -173,10 +174,15 @@ public final class PushDistributionListSendJob extends PushSendJob {
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies());
final SignalServiceStoryMessage storyMessage;
if (message.getStoryType().isTextStory()) {
storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), null, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies());
} else {
storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies());
}
return GroupSendUtil.sendStoryMessage(context, message.getRecipient().requireDistributionListId(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage);
} catch (ServerRejectedException e) {
throw new UndeliverableMessageException(e);

Wyświetl plik

@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.messages.GroupSendUtil;
import org.thoughtcrime.securesms.messages.StorySendUtil;
import org.thoughtcrime.securesms.mms.MessageGroupContext;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
@ -249,7 +250,12 @@ public final class PushGroupSendJob extends PushSendJob {
.withRevision(v2GroupProperties.getGroupRevision())
.build();
SignalServiceStoryMessage storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies());
final SignalServiceStoryMessage storyMessage;
if (message.getStoryType().isTextStory()) {
storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), groupContext, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies());
} else {
storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies());
}
return GroupSendUtil.sendGroupStoryMessage(context, groupId.requireV2(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage);
} else {

Wyświetl plik

@ -9,16 +9,16 @@ object TextStoryBackgroundColors {
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 191.41f,
colors = intArrayOf(0xFFF53844.toInt(), 0xFFF33845.toInt(), 0xFFEC3848.toInt(), 0xFFE2384C.toInt(), 0xFFD63851.toInt(), 0xFFC73857.toInt(), 0xFFB6385E.toInt(), 0xFFA43866.toInt(), 0xFF93376D.toInt(), 0xFF813775.toInt(), 0xFF70377C.toInt(), 0xFF613782.toInt(), 0xFF553787.toInt(), 0xFF4B378B.toInt(), 0xFF44378E.toInt(), 0xFF42378F.toInt()),
positions = floatArrayOf(0.2109f, 0.2168f, 0.2339f, 0.2611f, 0.2975f, 0.3418f, 0.3932f, 0.4506f, 0.5129f, 0.5791f, 0.6481f, 0.719f, 0.7907f, 0.8621f, 0.9322f, 1.0f)
colors = intArrayOf(0xFFF53844.toInt(), 0xFF42378F.toInt()),
positions = floatArrayOf(0f, 1.0f)
)
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 192.04f,
colors = intArrayOf(0xFFF04CE6.toInt(), 0xFFEE4BE6.toInt(), 0xFFE54AE5.toInt(), 0xFFD949E5.toInt(), 0xFFC946E4.toInt(), 0xFFB644E3.toInt(), 0xFFA141E3.toInt(), 0xFF8B3FE2.toInt(), 0xFF743CE1.toInt(), 0xFF5E39E0.toInt(), 0xFF4936DF.toInt(), 0xFF3634DE.toInt(), 0xFF2632DD.toInt(), 0xFF1930DD.toInt(), 0xFF112FDD.toInt(), 0xFF0E2FDD.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
colors = intArrayOf(0xFFF04CE6.toInt(), 0xFF0E2FDD.toInt()),
positions = floatArrayOf(0.0f, 1.0f)
),
),
ChatColors.forGradient(
@ -33,16 +33,16 @@ object TextStoryBackgroundColors {
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 180f,
colors = intArrayOf(0xFF0093E9.toInt(), 0xFF0294E9.toInt(), 0xFF0696E7.toInt(), 0xFF0D99E5.toInt(), 0xFF169EE3.toInt(), 0xFF21A3E0.toInt(), 0xFF2DA8DD.toInt(), 0xFF3AAEDA.toInt(), 0xFF46B5D6.toInt(), 0xFF53BBD3.toInt(), 0xFF5FC0D0.toInt(), 0xFF6AC5CD.toInt(), 0xFF73CACB.toInt(), 0xFF7ACDC9.toInt(), 0xFF7ECFC7.toInt(), 0xFF80D0C7.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
colors = intArrayOf(0xFF0093E9.toInt(), 0xFF80D0C7.toInt()),
positions = floatArrayOf(0.0f, 1.0f)
)
),
ChatColors.forGradient(
id = ChatColors.Id.NotSet,
linearGradient = ChatColors.LinearGradient(
degrees = 180f,
colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF64CDAB.toInt(), 0xFF60CBA8.toInt(), 0xFF5BC8A3.toInt(), 0xFF55C49D.toInt(), 0xFF4DC096.toInt(), 0xFF45BB8F.toInt(), 0xFF3CB687.toInt(), 0xFF33B17F.toInt(), 0xFF2AAC76.toInt(), 0xFF21A76F.toInt(), 0xFF1AA268.toInt(), 0xFF139F62.toInt(), 0xFF0E9C5E.toInt(), 0xFF0B9A5B.toInt(), 0xFF0A995A.toInt()),
positions = floatArrayOf(0.0f, 0.0807f, 0.1554f, 0.225f, 0.2904f, 0.3526f, 0.4125f, 0.471f, 0.529f, 0.5875f, 0.6474f, 0.7096f, 0.775f, 0.8446f, 0.9193f, 1.0f)
colors = intArrayOf(0xFF65CDAC.toInt(), 0xFF0A995A.toInt()),
positions = floatArrayOf(0.0f, 1.0f)
)
),
ChatColors.forColor(

Wyświetl plik

@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
@ -45,7 +46,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
private val viewModel: TextStoryPostSendViewModel by viewModels(
factoryProducer = {
TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository())
TextStoryPostSendViewModel.Factory(TextStoryPostSendRepository(requireContext()))
}
)
@ -99,6 +100,10 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
}
}
disposables += viewModel.untrustedIdentities.subscribe {
SafetyNumberChangeDialog.show(childFragmentManager, it)
}
searchField.doAfterTextChanged {
contactSearchMediator.onFilterChanged(it?.toString())
}
@ -158,9 +163,8 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
shareConfirmButton.isEnabled = false
val textStoryPostCreationState = creationViewModel.state.value
val linkPreviewState = linkPreviewViewModel.linkPreviewState.value
viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewState!!)
viewModel.onSend(contactSearchMediator.getSelectedContacts(), textStoryPostCreationState!!, linkPreviewViewModel.onSend().firstOrNull())
}
private fun animateInSelection() {

Wyświetl plik

@ -1,12 +1,30 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
import io.reactivex.rxjava3.core.Completable
import android.content.Context
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.Base64
class TextStoryPostSendRepository {
class TextStoryPostSendRepository(context: Context) {
private val context = context.applicationContext
fun isFirstSendToStory(shareContacts: Set<ContactSearchKey>): Boolean {
if (SignalStore.storyValues().userHasAddedToAStory) {
@ -16,8 +34,92 @@ class TextStoryPostSendRepository {
return shareContacts.any { it is ContactSearchKey.Story }
}
fun send(contactSearchKey: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Completable {
// TODO [stories] -- Implementation once we know what text post messages look like.
return Completable.complete()
fun send(contactSearchKey: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single<TextStoryPostSendResult> {
return checkForBadIdentityRecords(contactSearchKey).flatMap { result ->
if (result is TextStoryPostSendResult.Success) {
performSend(contactSearchKey, textStoryPostCreationState, linkPreview)
} else {
Single.just(result)
}
}
}
private fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey>): Single<TextStoryPostSendResult> {
return Single.fromCallable {
val recipients: List<Recipient> = contactSearchKeys
.filterIsInstance<RecipientSearchKey>()
.map { Recipient.resolved(it.recipientId) }
val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients)
if (identityRecordList.untrustedRecords.isNotEmpty()) {
TextStoryPostSendResult.UntrustedRecordsError(identityRecordList.untrustedRecords)
} else {
TextStoryPostSendResult.Success
}
}.subscribeOn(Schedulers.io())
}
private fun performSend(contactSearchKey: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single<TextStoryPostSendResult> {
return Single.fromCallable {
val messages: MutableList<OutgoingSecureMediaMessage> = mutableListOf()
for (contact in contactSearchKey) {
val recipient = Recipient.resolved(contact.requireShareContact().recipientId.get())
val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList
if (isStory && recipient.isActiveGroup) {
SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId())
}
val storyType: StoryType = when {
recipient.isDistributionList -> SignalDatabase.distributionLists.getStoryType(recipient.requireDistributionListId())
isStory -> StoryType.STORY_WITH_REPLIES
else -> StoryType.NONE
}
val message = OutgoingMediaMessage(
recipient,
serializeTextStoryState(textStoryPostCreationState),
emptyList(),
System.currentTimeMillis(),
-1,
0,
false,
ThreadDatabase.DistributionTypes.DEFAULT,
storyType.toTextStoryType(),
null,
null,
emptyList(),
listOfNotNull(linkPreview),
emptyList(),
mutableSetOf(),
mutableSetOf()
)
messages.add(OutgoingSecureMediaMessage(message))
ThreadUtil.sleep(5)
}
MessageSender.sendMediaBroadcast(context, messages, emptyList())
TextStoryPostSendResult.Success
}
}
private fun serializeTextStoryState(textStoryPostCreationState: TextStoryPostCreationState): String {
val builder = StoryTextPost.newBuilder()
builder.body = textStoryPostCreationState.body.toString()
builder.background = textStoryPostCreationState.backgroundColor.serialize()
builder.style = when (textStoryPostCreationState.textFont) {
TextFont.REGULAR -> StoryTextPost.Style.REGULAR
TextFont.BOLD -> StoryTextPost.Style.BOLD
TextFont.SERIF -> StoryTextPost.Style.SERIF
TextFont.SCRIPT -> StoryTextPost.Style.SCRIPT
TextFont.CONDENSED -> StoryTextPost.Style.CONDENSED
}
builder.textBackgroundColor = textStoryPostCreationState.textBackgroundColor
builder.textForegroundColor = textStoryPostCreationState.textForegroundColor
return Base64.encodeBytes(builder.build().toByteArray())
}
}

Wyświetl plik

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.mediasend.v2.text.send
import org.thoughtcrime.securesms.database.model.IdentityRecord
sealed class TextStoryPostSendResult {
object Success : TextStoryPostSendResult()
data class UntrustedRecordsError(val untrustedRecords: List<IdentityRecord>) : TextStoryPostSendResult()
}

Wyświetl plik

@ -3,20 +3,25 @@ package org.thoughtcrime.securesms.mediasend.v2.text.send
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
import org.thoughtcrime.securesms.util.livedata.Store
class TextStoryPostSendViewModel(private val repository: TextStoryPostSendRepository) : ViewModel() {
private val store = Store(TextStoryPostSendState.INIT)
private val untrustedIdentitySubject = PublishSubject.create<List<IdentityRecord>>()
private val disposables = CompositeDisposable()
val state: LiveData<TextStoryPostSendState> = store.stateLiveData
val untrustedIdentities: Observable<List<IdentityRecord>> = untrustedIdentitySubject
override fun onCleared() {
disposables.clear()
@ -36,14 +41,22 @@ class TextStoryPostSendViewModel(private val repository: TextStoryPostSendReposi
}
}
fun onSend(contactSearchKeys: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreviewState: LinkPreviewViewModel.LinkPreviewState) {
fun onSend(contactSearchKeys: Set<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?) {
store.update {
TextStoryPostSendState.SENDING
}
disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreviewState.linkPreview.orNull()).subscribeBy(
onComplete = {
store.update { TextStoryPostSendState.SENT }
disposables += repository.send(contactSearchKeys, textStoryPostCreationState, linkPreview).subscribeBy(
onSuccess = {
when (it) {
is TextStoryPostSendResult.Success -> {
store.update { TextStoryPostSendState.SENT }
}
is TextStoryPostSendResult.UntrustedRecordsError -> {
untrustedIdentitySubject.onNext(it.untrustedRecords)
store.update { TextStoryPostSendState.INIT }
}
}
},
onError = {
// TODO [stories] -- Error of some sort.

Wyświetl plik

@ -52,6 +52,9 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.database.model.StoryType;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor;
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost;
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPostOrBuilder;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.BadGroupIdException;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
@ -116,6 +119,7 @@ import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.Hex;
@ -139,6 +143,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTextAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
@ -165,10 +170,12 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.payments.Money;
import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@ -1333,9 +1340,9 @@ public final class MessageContentProcessor {
try {
final StoryType storyType;
if (message.getAllowsReplies().or(false)) {
storyType = StoryType.STORY_WITH_REPLIES;
storyType = StoryType.withReplies(message.getTextAttachment().isPresent());
} else {
storyType = StoryType.STORY_WITHOUT_REPLIES;
storyType = StoryType.withoutReplies(message.getTextAttachment().isPresent());
}
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(),
@ -1349,12 +1356,15 @@ public final class MessageContentProcessor {
false,
false,
content.isNeedsReceipt(),
Optional.absent(),
message.getTextAttachment().transform(this::serializeTextAttachment),
Optional.fromNullable(GroupUtil.getGroupContextIfPresent(content)),
message.getFileAttachment().transform(Collections::singletonList),
Optional.absent(),
Optional.absent(),
Optional.absent(),
getLinkPreviews(OptionalUtil.flatMap(message.getTextAttachment(),
t -> t.getPreview().transform(Collections::singletonList)),
"",
true),
Optional.absent(),
Optional.absent(),
content.getServerUuid());
@ -1382,6 +1392,64 @@ public final class MessageContentProcessor {
}
}
private @NonNull String serializeTextAttachment(@NonNull SignalServiceTextAttachment textAttachment) {
StoryTextPost.Builder builder = StoryTextPost.newBuilder();
if (textAttachment.getText().isPresent()) {
builder.setBody(textAttachment.getText().get());
}
if (textAttachment.getStyle().isPresent()) {
switch (textAttachment.getStyle().get()) {
case DEFAULT:
builder.setStyle(StoryTextPost.Style.DEFAULT);
break;
case REGULAR:
builder.setStyle(StoryTextPost.Style.REGULAR);
break;
case BOLD:
builder.setStyle(StoryTextPost.Style.BOLD);
break;
case SERIF:
builder.setStyle(StoryTextPost.Style.SERIF);
break;
case SCRIPT:
builder.setStyle(StoryTextPost.Style.SCRIPT);
break;
case CONDENSED:
builder.setStyle(StoryTextPost.Style.CONDENSED);
break;
}
}
if (textAttachment.getTextBackgroundColor().isPresent()) {
builder.setTextBackgroundColor(textAttachment.getTextBackgroundColor().get());
}
if (textAttachment.getTextForegroundColor().isPresent()) {
builder.setTextForegroundColor(textAttachment.getTextForegroundColor().get());
}
ChatColor.Builder chatColorBuilder = ChatColor.newBuilder();
if (textAttachment.getBackgroundColor().isPresent()) {
chatColorBuilder.setSingleColor(ChatColor.SingleColor.newBuilder().setColor(textAttachment.getBackgroundColor().get()));
} else if (textAttachment.getBackgroundGradient().isPresent()) {
SignalServiceTextAttachment.Gradient gradient = textAttachment.getBackgroundGradient().get();
ChatColor.LinearGradient.Builder linearGradientBuilder = ChatColor.LinearGradient.newBuilder();
linearGradientBuilder.setRotation(gradient.getAngle().or(0).floatValue());
linearGradientBuilder.addColors(gradient.getStartColor().get());
linearGradientBuilder.addColors(gradient.getEndColor().get());
linearGradientBuilder.addAllPositions(Arrays.asList(0f, 1f));
chatColorBuilder.setLinearGradient(linearGradientBuilder);
}
builder.setBackground(chatColorBuilder);
return Base64.encodeBytes(builder.build().toByteArray());
}
private void handleStoryReply(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message, @NonNull Recipient senderRecipient) throws StorageFailedException {
log(content.getTimestamp(), "Story reply.");
@ -1473,7 +1541,7 @@ public final class MessageContentProcessor {
try {
Optional<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts());
Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""));
Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""), false);
Optional<List<Mention>> mentions = getMentions(message.getMentions());
Optional<Attachment> sticker = getStickerAttachment(message.getSticker());
@ -1569,7 +1637,7 @@ public final class MessageContentProcessor {
Optional<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote());
Optional<Attachment> sticker = getStickerAttachment(message.getMessage().getSticker());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""));
Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""), false);
Optional<List<Mention>> mentions = getMentions(message.getMessage().getMentions());
boolean viewOnce = message.getMessage().isViewOnce();
List<Attachment> syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false))
@ -2314,7 +2382,7 @@ public final class MessageContentProcessor {
return Optional.of(contacts);
}
private Optional<List<LinkPreview>> getLinkPreviews(Optional<List<SignalServicePreview>> previews, @NonNull String message) {
private Optional<List<LinkPreview>> getLinkPreviews(Optional<List<SignalServicePreview>> previews, @NonNull String message, boolean isStoryEmbed) {
if (!previews.isPresent() || previews.get().isEmpty()) return Optional.absent();
List<LinkPreview> linkPreviews = new ArrayList<>(previews.get().size());
@ -2329,7 +2397,7 @@ public final class MessageContentProcessor {
boolean presentInBody = url.isPresent() && urlsInMessage.containsUrl(url.get());
boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get());
if (hasTitle && presentInBody && validDomain) {
if (hasTitle && (presentInBody || isStoryEmbed) && validDomain) {
LinkPreview linkPreview = new LinkPreview(url.get(), title.or(""), description.or(""), preview.getDate(), thumbnail);
linkPreviews.add(linkPreview);
} else {

Wyświetl plik

@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.messages
import com.google.protobuf.InvalidProtocolBufferException
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.util.Base64
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.messages.SignalServicePreview
import org.whispersystems.signalservice.api.messages.SignalServiceTextAttachment
import kotlin.math.roundToInt
object StorySendUtil {
@JvmStatic
@Throws(InvalidProtocolBufferException::class)
fun deserializeBodyToStoryTextAttachment(message: OutgoingMediaMessage, getPreviewsFor: (OutgoingMediaMessage) -> List<SignalServicePreview>): SignalServiceTextAttachment {
val storyTextPost = StoryTextPost.parseFrom(Base64.decode(message.body))
val preview = if (message.linkPreviews.isEmpty()) {
Optional.absent()
} else {
Optional.of(getPreviewsFor(message)[0])
}
return if (storyTextPost.background.hasLinearGradient()) {
SignalServiceTextAttachment.forGradientBackground(
Optional.fromNullable(storyTextPost.body),
Optional.fromNullable(getStyle(storyTextPost.style)),
Optional.of(storyTextPost.textForegroundColor),
Optional.of(storyTextPost.textBackgroundColor),
preview,
SignalServiceTextAttachment.Gradient(
Optional.of(storyTextPost.background.linearGradient.getColors(0)),
Optional.of(storyTextPost.background.linearGradient.getColors(1)),
Optional.of(storyTextPost.background.linearGradient.rotation.roundToInt())
)
)
} else {
SignalServiceTextAttachment.forSolidBackground(
Optional.fromNullable(storyTextPost.body),
Optional.fromNullable(getStyle(storyTextPost.style)),
Optional.of(storyTextPost.textForegroundColor),
Optional.of(storyTextPost.textBackgroundColor),
preview,
storyTextPost.background.singleColor.color
)
}
}
private fun getStyle(style: StoryTextPost.Style): SignalServiceTextAttachment.Style {
return when (style) {
StoryTextPost.Style.REGULAR -> SignalServiceTextAttachment.Style.REGULAR
StoryTextPost.Style.BOLD -> SignalServiceTextAttachment.Style.BOLD
StoryTextPost.Style.SERIF -> SignalServiceTextAttachment.Style.SERIF
StoryTextPost.Style.SCRIPT -> SignalServiceTextAttachment.Style.SCRIPT
StoryTextPost.Style.CONDENSED -> SignalServiceTextAttachment.Style.CONDENSED
else -> SignalServiceTextAttachment.Style.DEFAULT
}
}
}

Wyświetl plik

@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
import org.thoughtcrime.securesms.stickers.StickerRemoteUriLoader;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.ConversationShortcutPhoto;
import java.io.File;
@ -78,7 +79,9 @@ public class SignalGlideComponents implements RegisterGlideComponents {
registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder());
registry.prepend(BlurHash.class, Bitmap.class, new BlurHashResourceDecoder());
registry.prepend(StoryTextPostModel.class, Bitmap.class, new StoryTextPostModel.Decoder());
registry.append(StoryTextPostModel.class, StoryTextPostModel.class, UnitModelLoader.Factory.getInstance());
registry.append(ConversationShortcutPhoto.class, Bitmap.class, new ConversationShortcutPhoto.Loader.Factory(context));
registry.append(ContactPhoto.class, InputStream.class, new ContactPhotoLoader.Factory(context));
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));

Wyświetl plik

@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@ -85,7 +86,9 @@ import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
public class MessageSender {
@ -229,6 +232,7 @@ public class MessageSender {
attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId);
messageIds.add(primaryMessageId);
uploadLinkPreviews(primaryMessage, primaryMessageId, jobManager, preUploadJobIds, messageDependsOnIds);
if (messages.size() > 0) {
List<OutgoingSecureMediaMessage> secondaryMessages = messages.subList(1, messages.size());
@ -257,6 +261,7 @@ public class MessageSender {
attachmentDatabase.updateMessageId(attachmentIds, messageId);
messageIds.add(messageId);
uploadLinkPreviews(secondaryMessage, messageId, jobManager, preUploadJobIds, messageDependsOnIds);
}
for (int i = 0; i < attachmentCopies.size(); i++) {
@ -291,6 +296,31 @@ public class MessageSender {
}
}
@WorkerThread
private static void uploadLinkPreviews(@NonNull OutgoingMediaMessage mediaMessage,
long outgoingMessageId,
@NonNull JobManager jobManager,
@NonNull List<String> preUploadJobIds,
@NonNull List<String> messageDependsOnIds)
{
if (!mediaMessage.getLinkPreviews().isEmpty()) {
try {
MmsMessageRecord freshRecord = (MmsMessageRecord) SignalDatabase.mms().getMessageRecord(outgoingMessageId);
freshRecord.getLinkPreviews()
.stream()
.map(LinkPreview::getAttachmentId)
.filter(Objects::nonNull)
.forEach(previewId -> {
Job job = new AttachmentUploadJob(previewId);
jobManager.add(job, preUploadJobIds);
messageDependsOnIds.add(job.getId());
});
} catch (NoSuchMessageException e) {
throw new AssertionError("Cannot fetch record we just inserted");
}
}
}
/**
* @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't
* be enqueued (like in the case of a local self-send).

Wyświetl plik

@ -33,13 +33,11 @@ class StoryLinkPreviewView @JvmOverloads constructor(
private val title: TextView = findViewById(R.id.link_preview_title)
private val url: TextView = findViewById(R.id.link_preview_url)
fun bind(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE) {
if (linkPreviewState.linkPreview.isPresent) {
fun bind(linkPreview: LinkPreview?, hiddenVisibility: Int = View.INVISIBLE) {
if (linkPreview != null) {
visibility = View.VISIBLE
isClickable = true
val linkPreview: LinkPreview = linkPreviewState.linkPreview.get()
val corners = DimensionUnit.DP.toPixels(18f).toInt()
image.setCorners(corners, corners, corners, corners)
@ -60,6 +58,10 @@ class StoryLinkPreviewView @JvmOverloads constructor(
}
}
fun bind(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int = View.INVISIBLE) {
bind(linkPreviewState.linkPreview.orNull(), hiddenVisibility)
}
private fun formatUrl(linkPreview: LinkPreview) {
var domain: String? = null

Wyświetl plik

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.stories
import android.graphics.Bitmap
import android.view.View
import androidx.core.graphics.scale
import androidx.core.view.drawToBitmap
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.ResourceDecoder
import com.bumptech.glide.load.engine.Resource
import com.bumptech.glide.load.resource.SimpleResource
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.Base64
import java.security.MessageDigest
/**
* Glide model to render a StoryTextPost as a bitmap
*/
data class StoryTextPostModel(
private val storyTextPost: StoryTextPost
) : Key {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(storyTextPost.toByteArray())
}
companion object {
@JvmStatic
fun parseFrom(body: String): StoryTextPostModel {
return StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(body)))
}
}
class Decoder : ResourceDecoder<StoryTextPostModel, Bitmap> {
companion object {
private const val RENDER_WIDTH = 1080
private const val RENDER_HEIGHT = 1920
}
override fun handles(source: StoryTextPostModel, options: Options): Boolean = true
override fun decode(source: StoryTextPostModel, width: Int, height: Int, options: Options): Resource<Bitmap> {
val view = StoryTextPostView(ApplicationDependencies.getApplication())
view.measure(View.MeasureSpec.makeMeasureSpec(RENDER_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(RENDER_HEIGHT, View.MeasureSpec.EXACTLY))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
view.bindFromStoryTextPost(source.storyTextPost)
view.measure(View.MeasureSpec.makeMeasureSpec(RENDER_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(RENDER_HEIGHT, View.MeasureSpec.EXACTLY))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = view.drawToBitmap().scale(width, height)
return SimpleResource(bitmap)
}
}
}

Wyświetl plik

@ -6,6 +6,7 @@ import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
@ -18,12 +19,18 @@ import androidx.core.widget.doAfterTextChanged
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.fonts.Fonts
import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.mediasend.v2.text.TextAlignment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryScale
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
class StoryTextPostView @JvmOverloads constructor(
context: Context,
@ -116,10 +123,8 @@ class StoryTextPostView @JvmOverloads constructor(
setPostBackground(state.backgroundColor.chatBubbleMask)
setText(
if (state.body.isEmpty()) {
state.body.ifEmpty {
context.getString(R.string.TextStoryPostCreationFragment__tap_to_add_text)
} else {
state.body
},
state.body.isEmpty()
)
@ -134,6 +139,32 @@ class StoryTextPostView @JvmOverloads constructor(
postAdjustLinkPreviewTranslationY()
}
fun bindFromStoryTextPost(storyTextPost: StoryTextPost) {
linkPreviewView.visible = false
textAlignment = TextAlignment.CENTER
setPostBackground(ChatColors.forChatColor(ChatColors.Id.NotSet, storyTextPost.background).chatBubbleMask)
setText(storyTextPost.body, false)
setTextColor(storyTextPost.textForegroundColor)
setTextBackgroundColor(storyTextPost.textBackgroundColor)
setTextGravity(TextAlignment.CENTER)
when (val fontResult = Fonts.resolveFont(context, Locale.getDefault(), TextFont.fromStyle(storyTextPost.style))) {
is Fonts.FontResult.Immediate -> setTypeface(fontResult.typeface)
is Fonts.FontResult.Async -> setTypeface(fontResult.future.get())
}
hideCloseButton()
postAdjustTextTranslationX(TextAlignment.CENTER)
postAdjustLinkPreviewTranslationY()
}
fun bindLinkPreview(linkPreview: LinkPreview?) {
linkPreviewView.bind(linkPreview, View.GONE)
}
fun bindLinkPreviewState(linkPreviewState: LinkPreviewViewModel.LinkPreviewState, hiddenVisibility: Int) {
linkPreviewView.bind(linkPreviewState, hiddenVisibility)
}
@ -145,7 +176,7 @@ class StoryTextPostView @JvmOverloads constructor(
}
}
fun postAdjustTextTranslationX(textAlignment: TextAlignment) {
private fun postAdjustTextTranslationX(textAlignment: TextAlignment) {
doOnNextLayout {
adjustTextTranslationX(textAlignment)
}
@ -159,6 +190,10 @@ class StoryTextPostView @JvmOverloads constructor(
linkPreviewView.setOnCloseClickListener(onClickListener)
}
fun setLinkPreviewClickListener(onClickListener: OnClickListener?) {
linkPreviewView.setOnClickListener(onClickListener)
}
fun showPostContent() {
textView.alpha = 1f
linkPreviewView.alpha = 1f

Wyświetl plik

@ -10,9 +10,12 @@ import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.components.ThumbnailView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.StoryTextPostModel
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -80,11 +83,23 @@ object StoriesLandingItem {
val record = model.data.primaryStory.messageRecord as MediaMmsMessageRecord
avatarView.setStoryRingFromState(model.data.storyViewState)
storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true)
if (record.storyType.isTextStory) {
storyPreview.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(record.body))), 0, 0)
} else if (record.slideDeck.thumbnailSlide != null) {
storyPreview.setImageResource(GlideApp.with(storyPreview), record.slideDeck.thumbnailSlide!!, false, true)
} else {
storyPreview.clear(GlideApp.with(storyPreview))
}
if (model.data.secondaryStory != null) {
val secondaryRecord = model.data.secondaryStory.messageRecord as MediaMmsMessageRecord
storyMulti.setImageResource(GlideApp.with(storyPreview), secondaryRecord.slideDeck.thumbnailSlide!!, false, true)
if (secondaryRecord.storyType.isTextStory) {
storyMulti.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(secondaryRecord.body))), 0, 0)
} else {
storyMulti.setImageResource(GlideApp.with(storyPreview), secondaryRecord.slideDeck.thumbnailSlide!!, false, true)
}
storyMulti.visible = true
} else {
storyMulti.visible = false

Wyświetl plik

@ -10,7 +10,11 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.stories.StoryTextPostModel
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -55,8 +59,13 @@ object MyStoriesItem {
viewCount.text = context.resources.getQuantityString(R.plurals.MyStories__d_views, model.distributionStory.messageRecord.readReceiptCount, model.distributionStory.messageRecord.readReceiptCount)
date.text = DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), model.distributionStory.messageRecord.dateSent)
val thumbnail = (model.distributionStory.messageRecord as MmsMessageRecord).slideDeck.thumbnailSlide
if (thumbnail != null) {
val record: MmsMessageRecord = model.distributionStory.messageRecord as MmsMessageRecord
val thumbnail: Slide? = record.slideDeck.thumbnailSlide
@Suppress("CascadeIf")
if (record.storyType.isTextStory) {
storyPreview.setImageResource(GlideApp.with(storyPreview), StoryTextPostModel(StoryTextPost.parseFrom(Base64.decode(record.body))), 0, 0)
} else if (thumbnail != null) {
storyPreview.setImageResource(GlideApp.with(itemView), thumbnail, false, true)
} else {
storyPreview.clear(GlideApp.with(itemView))

Wyświetl plik

@ -1,8 +1,11 @@
package org.thoughtcrime.securesms.stories.viewer.page
import android.net.Uri
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
/**
* Each story is made up of a collection of posts
@ -15,7 +18,24 @@ class StoryPost(
val viewCount: Int,
val replyCount: Int,
val dateInMilliseconds: Long,
val attachment: Attachment,
val content: Content,
val conversationMessage: ConversationMessage,
val allowsReplies: Boolean
)
) {
sealed class Content(val uri: Uri?) {
class AttachmentContent(val attachment: Attachment) : Content(attachment.uri) {
override val transferState: Int = attachment.transferState
override fun isVideo(): Boolean = MediaUtil.isVideo(attachment)
}
class TextContent(uri: Uri, val recordId: Long) : Content(uri) {
override val transferState: Int = AttachmentDatabase.TRANSFER_PROGRESS_DONE
override fun isVideo(): Boolean = false
}
abstract val transferState: Int
abstract fun isVideo(): Boolean
}
}

Wyświetl plik

@ -46,12 +46,12 @@ import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel
import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment
import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment
import org.thoughtcrime.securesms.stories.viewer.reply.tabs.StoryViewsAndRepliesDialogFragment
import org.thoughtcrime.securesms.stories.viewer.text.StoryTextPostPreviewFragment
import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
import org.thoughtcrime.securesms.util.visible
@ -59,7 +59,12 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.abs
class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page), MediaPreviewFragment.Events, MultiselectForwardBottomSheet.Callback, StorySlateView.Callback {
class StoryViewerPageFragment :
Fragment(R.layout.stories_viewer_fragment_page),
MediaPreviewFragment.Events,
MultiselectForwardBottomSheet.Callback,
StorySlateView.Callback,
StoryTextPostPreviewFragment.Callback {
private lateinit var progressBar: SegmentedProgressBar
private lateinit var storySlate: StorySlateView
@ -140,7 +145,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
)
)
cardWrapper.setOnInterceptTouchEventListener { storySlate.state == StorySlateView.State.HIDDEN }
cardWrapper.setOnInterceptTouchEventListener { storySlate.state == StorySlateView.State.HIDDEN && childFragmentManager.findFragmentById(R.id.story_content_container) !is StoryTextPostPreviewFragment }
cardWrapper.setOnTouchListener { _, event ->
val result = gestureDetector.onTouchEvent(event)
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
@ -176,8 +181,8 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
}
override fun onRequestSegmentProgressPercentage(): Float? {
val attachmentUri = if (viewModel.hasPost() && MediaUtil.isVideo(viewModel.getPost().attachment)) {
viewModel.getPost().attachment.uri
val attachmentUri = if (viewModel.hasPost() && viewModel.getPost().content.isVideo()) {
viewModel.getPost().content.uri
} else {
null
}
@ -228,7 +233,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
val durations: Map<Int, Long> = state.posts
.mapIndexed { index, storyPost ->
index to if (MediaUtil.isVideo(storyPost.attachment)) -1L else TimeUnit.SECONDS.toMillis(5)
index to if (storyPost.content.isVideo()) -1L else TimeUnit.SECONDS.toMillis(5)
}
.toMap()
@ -336,7 +341,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
private fun resumeProgress() {
if (progressBar.segmentCount != 0 && viewModel.hasPost()) {
val postUri = viewModel.getPost().attachment.uri
val postUri = viewModel.getPost().content.uri
if (postUri != null) {
progressBar.start()
videoControlsDelegate.resume(postUri)
@ -385,12 +390,12 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
private fun presentStory(post: StoryPost, index: Int) {
val fragment = childFragmentManager.findFragmentById(R.id.story_content_container)
if (fragment != null && fragment.requireArguments().getParcelable<Uri>(MediaPreviewFragment.DATA_URI) == post.attachment.uri) {
if (fragment != null && fragment.requireArguments().getParcelable<Uri>(MediaPreviewFragment.DATA_URI) == post.content.uri) {
progressBar.setPosition(index)
return
}
if (post.attachment.uri == null) {
if (post.content.uri == null) {
progressBar.setPosition(index)
progressBar.invalidate()
} else {
@ -404,7 +409,7 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
}
private fun presentSlate(post: StoryPost) {
when (post.attachment.transferState) {
when (post.content.transferState) {
AttachmentDatabase.TRANSFER_PROGRESS_DONE -> {
storySlate.moveToState(StorySlateView.State.HIDDEN, post.id)
viewModel.setIsDisplayingSlate(false)
@ -448,7 +453,12 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
@SuppressLint("SetTextI18n")
private fun presentCaption(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View, storyPost: StoryPost) {
val displayBody = storyPost.conversationMessage.getDisplayBody(requireContext())
val displayBody = if (storyPost.content is StoryPost.Content.AttachmentContent) {
storyPost.conversationMessage.getDisplayBody(requireContext())
} else {
""
}
caption.text = displayBody
largeCaption.text = displayBody
caption.visible = displayBody.isNotEmpty()
@ -556,7 +566,14 @@ class StoryViewerPageFragment : Fragment(R.layout.stories_viewer_fragment_page),
}
private fun createFragmentForPost(storyPost: StoryPost): Fragment {
return MediaPreviewFragment.newInstance(storyPost.attachment, false)
return when (storyPost.content) {
is StoryPost.Content.AttachmentContent -> MediaPreviewFragment.newInstance(storyPost.content.attachment, false)
is StoryPost.Content.TextContent -> StoryTextPostPreviewFragment.create(storyPost.content)
}
}
override fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) {
viewModel.setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip)
}
override fun getVideoControlsDelegate(): VideoControlsDelegate {

Wyświetl plik

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.stories.viewer.page
import android.content.Context
import android.net.Uri
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
@ -71,7 +72,7 @@ class StoryViewerPageRepository(context: Context) {
viewCount = record.viewedReceiptCount,
replyCount = SignalDatabase.mms.getNumberOfStoryReplies(record.id),
dateInMilliseconds = record.dateSent,
attachment = (record as MmsMessageRecord).slideDeck.firstSlide!!.asAttachment(),
content = getContent(record as MmsMessageRecord),
conversationMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, record),
allowsReplies = record.storyType.isStoryWithReplies
)
@ -125,9 +126,11 @@ class StoryViewerPageRepository(context: Context) {
}
fun forceDownload(post: StoryPost) {
ApplicationDependencies.getJobManager().add(
AttachmentDownloadJob(post.id, (post.attachment as DatabaseAttachment).attachmentId, true)
)
if (post.content is StoryPost.Content.AttachmentContent) {
ApplicationDependencies.getJobManager().add(
AttachmentDownloadJob(post.id, (post.content.attachment as DatabaseAttachment).attachmentId, true)
)
}
}
fun getStoryPostsFor(recipientId: RecipientId): Observable<List<StoryPost>> {
@ -167,4 +170,17 @@ class StoryViewerPageRepository(context: Context) {
}
}
}
private fun getContent(record: MmsMessageRecord): StoryPost.Content {
return if (record.storyType.isTextStory) {
StoryPost.Content.TextContent(
uri = Uri.parse("story_text_post://${record.id}"),
recordId = record.id
)
} else {
StoryPost.Content.AttachmentContent(
attachment = record.slideDeck.asAttachments().first()
)
}
}
}

Wyświetl plik

@ -158,6 +158,10 @@ class StoryViewerPageViewModel(
storyViewerPlaybackStore.update { it.copy(areSegmentsInitialized = areSegmentsInitialized) }
}
fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingLinkPreviewTooltip = isDisplayingLinkPreviewTooltip) }
}
private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int = state.selectedPostIndex): StoryViewerPageState.ReplyState {
if (index !in state.posts.indices) {
return StoryViewerPageState.ReplyState.NONE

Wyświetl plik

@ -12,7 +12,8 @@ data class StoryViewerPlaybackState(
val isUserScrollingParent: Boolean = false,
val isSelectedPage: Boolean = false,
val isDisplayingSlate: Boolean = false,
val isFragmentResumed: Boolean = false
val isFragmentResumed: Boolean = false,
val isDisplayingLinkPreviewTooltip: Boolean = false
) {
val isPaused: Boolean = !areSegmentsInitialized ||
isUserTouching ||
@ -26,5 +27,6 @@ data class StoryViewerPlaybackState(
isUserScrollingParent ||
!isSelectedPage ||
isDisplayingSlate ||
!isFragmentResumed
!isFragmentResumed ||
isDisplayingLinkPreviewTooltip
}

Wyświetl plik

@ -92,7 +92,7 @@ class StoryReplyComposer @JvmOverloads constructor(
GlideApp.with(this),
messageRecord.dateSent,
messageRecord.recipient,
null,
messageRecord.body,
false,
messageRecord.slideDeck,
null

Wyświetl plik

@ -37,7 +37,7 @@ class StoryDirectReplyDialogFragment :
private val viewModel: StoryDirectReplyViewModel by viewModels(
factoryProducer = {
StoryDirectReplyViewModel.Factory(storyId, recipientId, StoryDirectReplyRepository())
StoryDirectReplyViewModel.Factory(storyId, recipientId, StoryDirectReplyRepository(requireContext()))
}
)

Wyświetl plik

@ -15,7 +15,9 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
class StoryDirectReplyRepository {
class StoryDirectReplyRepository(context: Context) {
private val context = context.applicationContext
fun getStoryPost(storyId: Long): Single<MessageRecord> {
return Single.fromCallable {
@ -23,7 +25,7 @@ class StoryDirectReplyRepository {
}.subscribeOn(Schedulers.io())
}
fun send(context: Context, storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable {
fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence): Completable {
return Completable.create { emitter ->
val message = SignalDatabase.mms.getMessageRecord(storyId) as MediaMmsMessageRecord
val (recipient, threadId) = if (groupDirectReplyRecipientId == null) {
@ -56,7 +58,7 @@ class StoryDirectReplyRepository {
0,
StoryType.NONE,
ParentStoryId.DirectReply(storyId),
QuoteModel(message.dateSent, quoteAuthor.id, "", false, message.slideDeck.asAttachments(), null),
QuoteModel(message.dateSent, quoteAuthor.id, message.body, false, message.slideDeck.asAttachments(), null),
emptyList(),
emptyList(),
emptyList(),

Wyświetl plik

@ -1,26 +1,21 @@
package org.thoughtcrime.securesms.stories.viewer.reply.direct
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
class StoryDirectReplyViewModel(
context: Context,
private val storyId: Long,
private val groupDirectReplyRecipientId: RecipientId?,
private val repository: StoryDirectReplyRepository
) : ViewModel() {
private val context = context.applicationContext
private val store = Store(StoryDirectReplyState())
private val disposables = CompositeDisposable()
@ -39,7 +34,7 @@ class StoryDirectReplyViewModel(
}
fun send(charSequence: CharSequence): Completable {
return repository.send(context, storyId, groupDirectReplyRecipientId, charSequence)
return repository.send(storyId, groupDirectReplyRecipientId, charSequence)
}
override fun onCleared() {
@ -54,7 +49,7 @@ class StoryDirectReplyViewModel(
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(
StoryDirectReplyViewModel(ApplicationDependencies.getApplication(), storyId, groupDirectReplyRecipientId, repository)
StoryDirectReplyViewModel(storyId, groupDirectReplyRecipientId, repository)
) as T
}
}

Wyświetl plik

@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.stories.viewer.text
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment
import org.thoughtcrime.securesms.stories.StoryTextPostView
import org.thoughtcrime.securesms.stories.viewer.page.StoryPost
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.fragments.requireListener
import kotlin.math.roundToInt
class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview_fragment) {
companion object {
private const val STORY_ID = "STORY_ID"
fun create(content: StoryPost.Content.TextContent): Fragment {
return StoryTextPostPreviewFragment().apply {
arguments = Bundle().apply {
putParcelable(MediaPreviewFragment.DATA_URI, content.uri)
putLong(STORY_ID, content.recordId)
}
}
}
}
private val viewModel: StoryTextPostViewModel by viewModels(
factoryProducer = {
StoryTextPostViewModel.Factory(requireArguments().getLong(STORY_ID), StoryTextPostRepository())
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val storyTextPostView: StoryTextPostView = view.findViewById(R.id.story_text_post)
viewModel.state.observe(viewLifecycleOwner) { state ->
when (state.loadState) {
StoryTextPostState.LoadState.INIT -> Unit
StoryTextPostState.LoadState.LOADED -> {
storyTextPostView.bindFromStoryTextPost(state.storyTextPost!!)
storyTextPostView.bindLinkPreview(state.linkPreview)
if (state.linkPreview != null) {
storyTextPostView.setLinkPreviewClickListener {
showLinkPreviewTooltip(it, state.linkPreview)
}
} else {
storyTextPostView.setLinkPreviewClickListener(null)
}
}
StoryTextPostState.LoadState.FAILED -> {
requireListener<MediaPreviewFragment.Events>().mediaNotAvailable()
}
}
}
}
@SuppressLint("AlertDialogBuilderUsage")
private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) {
requireListener<Callback>().setIsDisplayingLinkPreviewTooltip(true)
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.stories_link_popup, null, false)
contentView.findViewById<TextView>(R.id.url).text = linkPreview.url
contentView.setOnClickListener {
CommunicationActions.openBrowserLink(requireContext(), linkPreview.url)
}
contentView.measure(
View.MeasureSpec.makeMeasureSpec(DimensionUnit.DP.toPixels(275f).toInt(), View.MeasureSpec.EXACTLY),
0
)
contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
val alertDialog = AlertDialog.Builder(requireContext())
.setView(contentView)
.create()
alertDialog.window!!.attributes = alertDialog.window!!.attributes.apply {
val rootProjection = Projection.relativeToViewRoot(view.rootView, null)
val viewProjection = Projection.relativeToViewRoot(view, null).translateY(view.translationY)
val dialogBottom = rootProjection.height / 2f + contentView.measuredHeight / 2f
val linkPreviewViewTop = viewProjection.y
rootProjection.release()
viewProjection.release()
val delta = linkPreviewViewTop - dialogBottom
this.y = delta.roundToInt()
}
alertDialog.window!!.setDimAmount(0f)
alertDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
alertDialog.setOnDismissListener {
requireListener<Callback>().setIsDisplayingLinkPreviewTooltip(false)
}
alertDialog.show()
}
interface Callback {
fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean)
}
}

Wyświetl plik

@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.stories.viewer.text
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
class StoryTextPostRepository {
fun getRecord(recordId: Long): Single<MmsMessageRecord> {
return Single.fromCallable {
SignalDatabase.mms.getMessageRecord(recordId) as MmsMessageRecord
}.subscribeOn(Schedulers.io())
}
}

Wyświetl plik

@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.stories.viewer.text
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.linkpreview.LinkPreview
data class StoryTextPostState(
val storyTextPost: StoryTextPost? = null,
val linkPreview: LinkPreview? = null,
val loadState: LoadState = LoadState.INIT
) {
enum class LoadState {
INIT,
LOADED,
FAILED
}
}

Wyświetl plik

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.stories.viewer.text
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.livedata.Store
class StoryTextPostViewModel(recordId: Long, repository: StoryTextPostRepository) : ViewModel() {
private val store = Store(StoryTextPostState())
private val disposables = CompositeDisposable()
val state: LiveData<StoryTextPostState> = store.stateLiveData
init {
disposables += repository.getRecord(recordId).subscribeBy(
onSuccess = { record ->
store.update { state ->
state.copy(
storyTextPost = StoryTextPost.parseFrom(Base64.decode(record.body)),
linkPreview = record.linkPreviews.firstOrNull(),
loadState = StoryTextPostState.LoadState.LOADED
)
}
},
onError = {
store.update { state ->
state.copy(
loadState = StoryTextPostState.LoadState.FAILED
)
}
}
)
}
override fun onCleared() {
disposables.clear()
}
class Factory(private val recordId: Long, private val repository: StoryTextPostRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(StoryTextPostViewModel(recordId, repository)) as T
}
}
}

Wyświetl plik

@ -86,9 +86,9 @@ public final class FeatureFlags {
private static final String DONOR_BADGES = "android.donorBadges.6";
private static final String DONOR_BADGES_DISPLAY = "android.donorBadges.display.4";
private static final String CDSH = "android.cdsh";
private static final String STORIES = "android.stories";
private static final String STORIES = "android.stories.2";
private static final String STORIES_TEXT_FUNCTIONS = "android.stories.text.functions";
private static final String STORIES_TEXT_POSTS = "android.stories.text.posts";
private static final String STORIES_TEXT_POSTS = "android.stories.text.posts.2";
private static final String HARDWARE_AEC_BLOCKLIST_MODELS = "android.calling.hardwareAecBlockList";
private static final String SOFTWARE_AEC_BLOCKLIST_MODELS = "android.calling.softwareAecBlockList";
private static final String USE_HARDWARE_AEC_IF_OLD = "android.calling.useHardwareAecIfOlderThanApi29";

Wyświetl plik

@ -204,4 +204,21 @@ message CustomAvatar {
Vector vector = 2;
Photo photo = 3;
}
}
message StoryTextPost {
enum Style {
DEFAULT = 0;
REGULAR = 1;
BOLD = 2;
SERIF = 3;
SCRIPT = 4;
CONDENSED = 5;
}
string body = 1;
Style style = 2;
int32 textForegroundColor = 3;
int32 textBackgroundColor = 4;
ChatColor background = 5;
}

Wyświetl plik

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="275dp"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="5dp">
<View
android:id="@+id/bottom_arrow"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginTop="24dp"
android:background="@color/core_black"
android:rotation="45"
app:layout_constraintBottom_toBottomOf="@id/bubble"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/bubble" />
<androidx.cardview.widget.CardView
android:id="@+id/bubble"
android:layout_width="0dp"
android:layout_height="0dp"
android:alpha="0.9"
app:cardBackgroundColor="@color/core_black"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:layout_constraintBottom_toBottomOf="@id/url"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/visit_link" />
<TextView
android:id="@+id/visit_link"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:paddingTop="14dp"
android:text="@string/StoriesLinkPopup__visit_link"
android:textAppearance="@style/TextAppearance.Signal.Body2.Bold"
android:textColor="@color/core_white"
app:layout_constraintEnd_toStartOf="@id/chevron"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/url"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:paddingTop="1dp"
android:paddingBottom="15dp"
android:textAppearance="@style/TextAppearance.Signal.Caption"
android:textColor="@color/core_white"
app:layout_constraintEnd_toStartOf="@id/chevron"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/visit_link"
tools:text="https://www.signal.org/blog/entry/20/20/20/20/20" />
<ImageView
android:id="@+id/chevron"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="16dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="@id/bubble"
app:layout_constraintEnd_toEndOf="@id/bubble"
app:layout_constraintTop_toTopOf="@id/bubble"
app:srcCompat="@drawable/exo_ic_chevron_right" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -8,7 +8,7 @@
android:paddingEnd="@dimen/dsl_settings_gutter"
android:background="?selectableItemBackground">
<org.thoughtcrime.securesms.components.ThumbnailView
<org.thoughtcrime.securesms.components.OutlinedThumbnailView
android:id="@+id/story"
android:layout_width="56dp"
android:layout_height="84dp"
@ -17,6 +17,8 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:otv_cornerRadius="12dp"
app:otv_strokeColor="@color/signal_background_primary"
tools:background="@color/red" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.stories.StoryTextPostView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/story_text_post"
android:layout_width="match_parent"
android:layout_height="match_parent" />

Wyświetl plik

@ -4614,6 +4614,7 @@
<!-- endregion -->
<!-- Content description for expand contacts chevron -->
<string name="ExpandModel__view_more">View more</string>
<string name="StoriesLinkPopup__visit_link">Visit link</string>
<!-- EOF -->