diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java index bf1b66e7dd..f6137294c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.java @@ -1,17 +1,23 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.ParcelCompat; import org.thoughtcrime.securesms.audio.AudioHash; import org.thoughtcrime.securesms.blurhash.BlurHash; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties; import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.ParcelUtil; -public abstract class Attachment { +import java.util.Objects; + +public abstract class Attachment implements Parcelable { @NonNull private final String contentType; @@ -116,6 +122,69 @@ public abstract class Attachment { this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty(); } + protected Attachment(Parcel in) { + this.contentType = Objects.requireNonNull(in.readString()); + this.transferState = in.readInt(); + this.size = in.readLong(); + this.fileName = in.readString(); + this.cdnNumber = in.readInt(); + this.location = in.readString(); + this.key = in.readString(); + this.relay = in.readString(); + this.digest = ParcelUtil.readByteArray(in); + this.incrementalDigest = ParcelUtil.readByteArray(in); + this.fastPreflightId = in.readString(); + this.voiceNote = ParcelUtil.readBoolean(in); + this.borderless = ParcelUtil.readBoolean(in); + this.videoGif = ParcelUtil.readBoolean(in); + this.width = in.readInt(); + this.height = in.readInt(); + this.incrementalMacChunkSize = in.readInt(); + this.quote = ParcelUtil.readBoolean(in); + this.uploadTimestamp = in.readLong(); + this.stickerLocator = ParcelCompat.readParcelable(in, StickerLocator.class.getClassLoader(), StickerLocator.class); + this.caption = in.readString(); + this.blurHash = ParcelCompat.readParcelable(in, BlurHash.class.getClassLoader(), BlurHash.class); + this.audioHash = ParcelCompat.readParcelable(in, AudioHash.class.getClassLoader(), AudioHash.class); + this.transformProperties = Objects.requireNonNull(ParcelCompat.readParcelable(in, TransformProperties.class.getClassLoader(), TransformProperties.class)); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + AttachmentCreator.writeSubclass(dest, this); + dest.writeString(contentType); + dest.writeInt(transferState); + dest.writeLong(size); + dest.writeString(fileName); + dest.writeInt(cdnNumber); + dest.writeString(location); + dest.writeString(key); + dest.writeString(relay); + ParcelUtil.writeByteArray(dest, digest); + ParcelUtil.writeByteArray(dest, incrementalDigest); + dest.writeString(fastPreflightId); + ParcelUtil.writeBoolean(dest, voiceNote); + ParcelUtil.writeBoolean(dest, borderless); + ParcelUtil.writeBoolean(dest, videoGif); + dest.writeInt(width); + dest.writeInt(height); + dest.writeInt(incrementalMacChunkSize); + ParcelUtil.writeBoolean(dest, quote); + dest.writeLong(uploadTimestamp); + dest.writeParcelable(stickerLocator, 0); + dest.writeString(caption); + dest.writeParcelable(blurHash, 0); + dest.writeParcelable(audioHash, 0); + dest.writeParcelable(transformProperties, 0); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = AttachmentCreator.INSTANCE; + @Nullable public abstract Uri getUri(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentCreator.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentCreator.kt new file mode 100644 index 0000000000..99b814a2a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentCreator.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.attachments + +import android.os.Parcel +import android.os.Parcelable + +/** + * Parcelable Creator for Attachments. Encapsulates the logic around dealing with + * subclasses, since Attachment is abstract and has several children. + */ +object AttachmentCreator : Parcelable.Creator { + enum class Subclass(val clazz: Class, val code: String) { + DATABASE(DatabaseAttachment::class.java, "database"), + MMS_NOTIFICATION(MmsNotificationAttachment::class.java, "mms_notification"), + POINTER(PointerAttachment::class.java, "pointer"), + TOMBSTONE(TombstoneAttachment::class.java, "tombstone"), + URI(UriAttachment::class.java, "uri") + } + + @JvmStatic + fun writeSubclass(dest: Parcel, instance: Attachment) { + val subclass = Subclass.values().firstOrNull { it.clazz == instance::class.java } ?: error("Unexpected subtype ${instance::class.java.simpleName}") + dest.writeString(subclass.code) + } + + override fun createFromParcel(source: Parcel): Attachment { + val rawCode = source.readString()!! + + return when (Subclass.values().first { rawCode == it.code }) { + Subclass.DATABASE -> DatabaseAttachment(source) + Subclass.MMS_NOTIFICATION -> MmsNotificationAttachment(source) + Subclass.POINTER -> PointerAttachment(source) + Subclass.TOMBSTONE -> TombstoneAttachment(source) + Subclass.URI -> UriAttachment(source) + } + } + + override fun newArray(size: Int): Array = arrayOfNulls(size) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java index 650bf80ae8..c63b92098d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.ParcelCompat; import org.thoughtcrime.securesms.audio.AudioHash; import org.thoughtcrime.securesms.blurhash.BlurHash; @@ -10,6 +13,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ParcelUtil; import java.util.Comparator; @@ -59,6 +63,25 @@ public class DatabaseAttachment extends Attachment { this.displayOrder = displayOrder; } + protected DatabaseAttachment(Parcel in) { + super(in); + this.attachmentId = ParcelCompat.readParcelable(in, AttachmentId.class.getClassLoader(), AttachmentId.class); + this.hasData = ParcelUtil.readBoolean(in); + this.hasThumbnail = ParcelUtil.readBoolean(in); + this.mmsId = in.readLong(); + this.displayOrder = in.readInt(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeParcelable(attachmentId, 0); + ParcelUtil.writeBoolean(dest, hasData); + ParcelUtil.writeBoolean(dest, hasThumbnail); + dest.writeLong(mmsId); + dest.writeInt(displayOrder); + } + @Override @Nullable public Uri getUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java index c616f91139..9bc99d5c40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/MmsNotificationAttachment.java @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.database.AttachmentTable; @@ -14,6 +16,10 @@ public class MmsNotificationAttachment extends Attachment { super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, false, 0, null, null, null, null, null); } + protected MmsNotificationAttachment(Parcel in) { + super(in); + } + @Nullable @Override public Uri getUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java index 9c87d5d267..247f5cac50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -46,6 +47,10 @@ public class PointerAttachment extends Attachment { super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null); } + protected PointerAttachment(Parcel in) { + super(in); + } + @Nullable @Override public Uri getUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java index 4c9ec6e09e..43d89ae7cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -19,6 +20,10 @@ public class TombstoneAttachment extends Attachment { super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, quote, 0, null, null, null, null, null); } + protected TombstoneAttachment(Parcel in) { + super(in); + } + @Override public @Nullable Uri getUri() { return null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java index d7d03fa3bc..eb0c7bcd26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.java @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.attachments; import android.net.Uri; +import android.os.Parcel; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.ParcelCompat; import org.thoughtcrime.securesms.audio.AudioHash; import org.thoughtcrime.securesms.blurhash.BlurHash; @@ -56,6 +58,17 @@ public class UriAttachment extends Attachment { this.dataUri = Objects.requireNonNull(dataUri); } + protected UriAttachment(Parcel in) { + super(in); + this.dataUri = Objects.requireNonNull(ParcelCompat.readParcelable(in, Uri.class.getClassLoader(), Uri.class)); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeParcelable(dataUri, 0); + } + @Override @NonNull public Uri getUri() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java index 73c7f48ee3..703467828e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioHash.java @@ -1,17 +1,22 @@ package org.thoughtcrime.securesms.audio; +import android.os.Parcel; +import android.os.Parcelable; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData; +import org.thoughtcrime.securesms.util.ParcelUtil; import org.whispersystems.util.Base64; import java.io.IOException; +import java.util.Objects; /** * An AudioHash is a compact string representation of the wave form and duration for an audio file. */ -public final class AudioHash { +public final class AudioHash implements Parcelable { @NonNull private final String hash; @NonNull private final AudioWaveFormData audioWaveForm; @@ -25,6 +30,39 @@ public final class AudioHash { this(Base64.encodeBytes(audioWaveForm.encode()), audioWaveForm); } + protected AudioHash(Parcel in) { + hash = Objects.requireNonNull(in.readString()); + + try { + audioWaveForm = AudioWaveFormData.ADAPTER.decode(Objects.requireNonNull(ParcelUtil.readByteArray(in))); + } catch (IOException e) { + throw new AssertionError(); + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(hash); + ParcelUtil.writeByteArray(dest, audioWaveForm.encode()); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator<>() { + @Override + public AudioHash createFromParcel(Parcel in) { + return new AudioHash(in); + } + + @Override + public AudioHash[] newArray(int size) { + return new AudioHash[size]; + } + }; + public static @Nullable AudioHash parseOrNull(@Nullable String hash) { if (hash == null) return null; try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 5110dd276b..009e804c70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -314,6 +314,7 @@ import org.thoughtcrime.securesms.util.getRecordQuoteType import org.thoughtcrime.securesms.util.hasAudio import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.isValidReactionTarget +import org.thoughtcrime.securesms.util.savedStateViewModel import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible @@ -405,10 +406,8 @@ class ConversationFragment : ) } - private val linkPreviewViewModel: LinkPreviewViewModelV2 by viewModel { - LinkPreviewViewModelV2( - enablePlaceholder = false - ) + private val linkPreviewViewModel: LinkPreviewViewModelV2 by savedStateViewModel { + LinkPreviewViewModelV2(it, enablePlaceholder = false) } private val groupCallViewModel: ConversationGroupCallViewModel by viewModel { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java index 61ad849ccd..07cfe2b1a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java @@ -20,6 +20,8 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.media.MediaDataSource; +import android.os.Parcel; +import android.os.Parcelable; import android.text.TextUtils; import android.util.Pair; @@ -1628,7 +1630,7 @@ public class AttachmentTable extends DatabaseTable { } } - public static final class TransformProperties { + public static final class TransformProperties implements Parcelable { private static final int DEFAULT_MEDIA_QUALITY = SentMediaQuality.STANDARD.getCode(); @@ -1652,6 +1654,40 @@ public class AttachmentTable extends DatabaseTable { this.sentMediaQuality = sentMediaQuality; } + protected TransformProperties(Parcel in) { + skipTransform = in.readByte() != 0; + videoTrim = in.readByte() != 0; + videoTrimStartTimeUs = in.readLong(); + videoTrimEndTimeUs = in.readLong(); + sentMediaQuality = in.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (skipTransform ? 1 : 0)); + dest.writeByte((byte) (videoTrim ? 1 : 0)); + dest.writeLong(videoTrimStartTimeUs); + dest.writeLong(videoTrimEndTimeUs); + dest.writeInt(sentMediaQuality); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator<>() { + @Override + public TransformProperties createFromParcel(Parcel in) { + return new TransformProperties(in); + } + + @Override + public TransformProperties[] newArray(int size) { + return new TransformProperties[size]; + } + }; + public static @NonNull TransformProperties empty() { return new TransformProperties(false, false, 0, 0, DEFAULT_MEDIA_QUALITY); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java index 3d486abddd..527752c9ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreview.java @@ -1,7 +1,11 @@ package org.thoughtcrime.securesms.linkpreview; +import android.os.Parcel; +import android.os.Parcelable; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.ParcelCompat; import androidx.core.text.HtmlCompat; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -15,7 +19,7 @@ import org.thoughtcrime.securesms.util.JsonUtils; import java.io.IOException; import java.util.Optional; -public class LinkPreview { +public class LinkPreview implements Parcelable { @JsonProperty private final String url; @@ -67,6 +71,42 @@ public class LinkPreview { this.thumbnail = Optional.empty(); } + protected LinkPreview(Parcel in) { + url = in.readString(); + title = in.readString(); + description = in.readString(); + date = in.readLong(); + attachmentId = ParcelCompat.readParcelable(in, AttachmentId.class.getClassLoader(), AttachmentId.class); + thumbnail = Optional.ofNullable(ParcelCompat.readParcelable(in, Attachment.class.getClassLoader(), Attachment.class)); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(url); + dest.writeString(title); + dest.writeString(description); + dest.writeLong(date); + dest.writeParcelable(attachmentId, flags); + dest.writeParcelable(thumbnail.orElse(null), 0); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public LinkPreview createFromParcel(Parcel in) { + return new LinkPreview(in); + } + + @Override + public LinkPreview[] newArray(int size) { + return new LinkPreview[size]; + } + }; + public @NonNull String getUrl() { return url; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.java deleted file mode 100644 index 86f7bd0feb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.linkpreview; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Optional; - -public class LinkPreviewState { - private final String activeUrlForError; - private final boolean isLoading; - private final boolean hasLinks; - private final Optional linkPreview; - private final LinkPreviewRepository.Error error; - - private LinkPreviewState(@Nullable String activeUrlForError, - boolean isLoading, - boolean hasLinks, - Optional linkPreview, - @Nullable LinkPreviewRepository.Error error) - { - this.activeUrlForError = activeUrlForError; - this.isLoading = isLoading; - this.hasLinks = hasLinks; - this.linkPreview = linkPreview; - this.error = error; - } - - public static LinkPreviewState forLoading() { - return new LinkPreviewState(null, true, false, Optional.empty(), null); - } - - public static LinkPreviewState forPreview(@NonNull LinkPreview linkPreview) { - return new LinkPreviewState(null, false, true, Optional.of(linkPreview), null); - } - - public static LinkPreviewState forLinksWithNoPreview(@Nullable String activeUrlForError, @NonNull LinkPreviewRepository.Error error) { - return new LinkPreviewState(activeUrlForError, false, true, Optional.empty(), error); - } - - public static LinkPreviewState forNoLinks() { - return new LinkPreviewState(null, false, false, Optional.empty(), null); - } - - public @Nullable String getActiveUrlForError() { - return activeUrlForError; - } - - public boolean isLoading() { - return isLoading; - } - - public boolean hasLinks() { - return hasLinks; - } - - public Optional getLinkPreview() { - return linkPreview; - } - - public @Nullable LinkPreviewRepository.Error getError() { - return error; - } - - public boolean hasContent() { - return isLoading || hasLinks; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.kt new file mode 100644 index 0000000000..9d475d9ff4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewState.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.linkpreview + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.util.Optional + +@Parcelize +class LinkPreviewState private constructor( + @JvmField val activeUrlForError: String?, + @JvmField val isLoading: Boolean, + private val hasLinks: Boolean, + private val preview: LinkPreview?, + @JvmField val error: LinkPreviewRepository.Error? +) : Parcelable { + + @IgnoredOnParcel + @JvmField + val linkPreview: Optional = Optional.ofNullable(preview) + + fun hasLinks(): Boolean { + return hasLinks + } + + fun hasContent(): Boolean { + return isLoading || hasLinks + } + + companion object { + @JvmStatic + fun forLoading(): LinkPreviewState { + return LinkPreviewState( + activeUrlForError = null, + isLoading = true, + hasLinks = false, + preview = null, + error = null + ) + } + + @JvmStatic + fun forPreview(linkPreview: LinkPreview): LinkPreviewState { + return LinkPreviewState( + activeUrlForError = null, + isLoading = false, + hasLinks = true, + preview = linkPreview, + error = null + ) + } + + @JvmStatic + fun forLinksWithNoPreview(activeUrlForError: String?, error: LinkPreviewRepository.Error): LinkPreviewState { + return LinkPreviewState( + activeUrlForError = activeUrlForError, + isLoading = false, + hasLinks = true, + preview = null, + error = error + ) + } + + @JvmStatic + fun forNoLinks(): LinkPreviewState { + return LinkPreviewState( + activeUrlForError = null, + isLoading = false, + hasLinks = false, + preview = null, + error = 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 4b8d3c84c6..d20c0ab0ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModel.java @@ -50,14 +50,6 @@ public class LinkPreviewViewModel extends ViewModel { return linkPreviewSafeState; } - public boolean hasLinkPreview() { - return linkPreviewSafeState.getValue() != null && linkPreviewSafeState.getValue().getLinkPreview().isPresent(); - } - - public boolean hasLinkPreviewUi() { - return linkPreviewSafeState.getValue() != null && linkPreviewSafeState.getValue().hasContent(); - } - /** * Gets the current state for use in the UI, then resets local state to prepare for the next message send. */ @@ -75,10 +67,10 @@ public class LinkPreviewViewModel extends ViewModel { debouncer.clear(); linkPreviewState.setValue(LinkPreviewState.forNoLinks()); - if (currentState == null || !currentState.getLinkPreview().isPresent()) { + if (currentState == null || !currentState.linkPreview.isPresent()) { return Collections.emptyList(); } else { - return Collections.singletonList(currentState.getLinkPreview().get()); + return Collections.singletonList(currentState.linkPreview.get()); } } @@ -101,14 +93,14 @@ public class LinkPreviewViewModel extends ViewModel { if (currentState == null) { return Collections.emptyList(); - } else if (currentState.getLinkPreview().isPresent()) { - return Collections.singletonList(currentState.getLinkPreview().get()); - } else if (currentState.getActiveUrlForError() != null) { - String topLevelDomain = LinkPreviewUtil.getTopLevelDomain(currentState.getActiveUrlForError()); + } else if (currentState.linkPreview.isPresent()) { + return Collections.singletonList(currentState.linkPreview.get()); + } else if (currentState.activeUrlForError != null) { + String topLevelDomain = LinkPreviewUtil.getTopLevelDomain(currentState.activeUrlForError); AttachmentId attachmentId = null; - return Collections.singletonList(new LinkPreview(currentState.getActiveUrlForError(), - topLevelDomain != null ? topLevelDomain : currentState.getActiveUrlForError(), + return Collections.singletonList(new LinkPreview(currentState.activeUrlForError, + topLevelDomain != null ? topLevelDomain : currentState.activeUrlForError, null, -1L, attachmentId)); @@ -255,7 +247,7 @@ public class LinkPreviewViewModel extends ViewModel { } if (enablePlaceholder) { - return state.getLinkPreview() + return state.linkPreview .map(linkPreview -> LinkPreviewState.forLinksWithNoPreview(linkPreview.getUrl(), LinkPreviewRepository.Error.PREVIEW_NOT_AVAILABLE)) .orElse(state); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModelV2.kt index b48e6133b6..baf9a83776 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModelV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewViewModelV2.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.linkpreview +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable @@ -22,23 +23,50 @@ import java.util.Optional * Rewrite of [LinkPreviewViewModel] preferring Rx and Kotlin */ class LinkPreviewViewModelV2( + private val savedStateHandle: SavedStateHandle, private val linkPreviewRepository: LinkPreviewRepository = LinkPreviewRepository(), private val enablePlaceholder: Boolean ) : ViewModel() { - private var enabled = SignalStore.settings().isLinkPreviewsEnabled - private val linkPreviewStateStore = RxStore(LinkPreviewState.forNoLinks()) - val linkPreviewState: Flowable = linkPreviewStateStore.stateFlowable + companion object { + private const val ACTIVE_URL = "active_url" + private const val USER_CANCELLED = "user_cancelled" + private const val LINK_PREVIEW_STATE = "link_preview_state" + } + + private var enabled = SignalStore.settings().isLinkPreviewsEnabled + private val linkPreviewStateStore = RxStore(savedStateHandle[LINK_PREVIEW_STATE] ?: LinkPreviewState.forNoLinks()) + + val linkPreviewState: Flowable = linkPreviewStateStore.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + val linkPreviewStateSnapshot: LinkPreviewState = linkPreviewStateStore.state + val hasLinkPreview: Boolean = linkPreviewStateStore.state.linkPreview.isPresent val hasLinkPreviewUi: Boolean = linkPreviewStateStore.state.hasContent() - private var activeUrl: String? = null + private var activeUrl: String? + get() = savedStateHandle[ACTIVE_URL] + set(value) { + savedStateHandle[ACTIVE_URL] = value + } + private var userCancelled: Boolean + get() = savedStateHandle[USER_CANCELLED] ?: false + set(value) { + savedStateHandle[USER_CANCELLED] = value + } + private var activeRequest: Disposable = Disposable.disposed() - private var userCancelled: Boolean = false private val debouncer: Debouncer = Debouncer(250) + private var savedStateDisposable: Disposable = linkPreviewStateStore + .stateFlowable + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { + savedStateHandle[LINK_PREVIEW_STATE] = it + } + override fun onCleared() { activeRequest.dispose() + savedStateDisposable.dispose() debouncer.clear() } @@ -46,6 +74,7 @@ class LinkPreviewViewModelV2( val currentState = linkPreviewStateStore.state onUserCancel() + userCancelled = false return currentState.linkPreview.map { listOf(it) }.orElse(emptyList()) } @@ -143,7 +172,7 @@ class LinkPreviewViewModelV2( } } - private fun setLinkPreviewState(linkPreviewState: LinkPreviewState) { + fun setLinkPreviewState(linkPreviewState: LinkPreviewState) { linkPreviewStateStore.update { cleanseState(linkPreviewState) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt index 34e219483f..fb28444fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -14,15 +14,16 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBinding import org.thoughtcrime.securesms.linkpreview.LinkPreview -import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewState -import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2 import org.thoughtcrime.securesms.mediasend.CameraDisplay import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel @@ -31,7 +32,8 @@ import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendReposi import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.Stories -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil +import org.thoughtcrime.securesms.util.activitySavedStateViewModel +import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.visible import java.util.Optional @@ -55,14 +57,9 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati } ) - private val linkPreviewViewModel: LinkPreviewViewModel by viewModels( - ownerProducer = { - requireActivity() - }, - factoryProducer = { - LinkPreviewViewModel.Factory(LinkPreviewRepository(), true) - } - ) + private val linkPreviewViewModel: LinkPreviewViewModelV2 by activitySavedStateViewModel { handle -> + LinkPreviewViewModelV2(handle, enablePlaceholder = true) + } private val lifecycleDisposable = LifecycleDisposable() @@ -80,16 +77,16 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati } } - viewModel.typeface.observe(viewLifecycleOwner) { typeface -> + lifecycleDisposable += viewModel.typeface.subscribeBy { typeface -> binding.storyTextPost.setTypeface(typeface) } - viewModel.state.observe(viewLifecycleOwner) { state -> + lifecycleDisposable += viewModel.state.subscribeBy { state -> binding.backgroundSelector.background = state.backgroundColor.chatBubbleMask binding.storyTextPost.bindFromCreationState(state) if (state.linkPreviewUri != null) { - linkPreviewViewModel.onTextChanged(requireContext(), state.linkPreviewUri, 0, state.linkPreviewUri.lastIndex) + linkPreviewViewModel.onTextChanged(state.linkPreviewUri, 0, state.linkPreviewUri.lastIndex) } else { linkPreviewViewModel.onSend() } @@ -99,9 +96,9 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati binding.send.isEnabled = canSend } - LiveDataUtil.combineLatest(viewModel.state, linkPreviewViewModel.linkPreviewState) { viewState, linkState -> + lifecycleDisposable += Flowable.combineLatest(viewModel.state, linkPreviewViewModel.linkPreviewState) { viewState, linkState -> Pair(viewState.body.isBlank(), linkState) - }.observe(viewLifecycleOwner) { (useLargeThumb, linkState) -> + }.subscribeBy { (useLargeThumb, linkState) -> binding.storyTextPost.bindLinkPreviewState(linkState, View.GONE, useLargeThumb) binding.storyTextPost.postAdjustLinkPreviewTranslationY() } @@ -145,7 +142,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati if (contacts.isEmpty()) { val bitmap = binding.storyTextPost.drawToBitmap() - viewModel.compressToBlob(bitmap).observeOn(AndroidSchedulers.mainThread()).subscribe { uri -> + lifecycleDisposable += viewModel.compressToBlob(bitmap).observeOn(AndroidSchedulers.mainThread()).subscribe { uri -> launcher.launch( StoriesMultiselectForwardActivity.Args( MultiselectForwardFragmentArgs( @@ -246,7 +243,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati } private fun getLinkPreview(): LinkPreview? { - val linkPreviewState: LinkPreviewState = linkPreviewViewModel.linkPreviewState.value ?: return null + val linkPreviewState: LinkPreviewState = linkPreviewViewModel.linkPreviewStateSnapshot return if (linkPreviewState.linkPreview.isPresent) { linkPreviewState.linkPreview.get() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt index 3054270978..36ea4b7f30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt @@ -5,13 +5,15 @@ import android.graphics.Typeface import android.net.Uri import android.os.Bundle import androidx.annotation.ColorInt -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.Subject @@ -25,32 +27,32 @@ import org.thoughtcrime.securesms.fonts.TypefaceCache import org.thoughtcrime.securesms.linkpreview.LinkPreview import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult -import org.thoughtcrime.securesms.util.livedata.Store +import org.thoughtcrime.securesms.util.rx.RxStore class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRepository, private val identityChangesSince: Long = System.currentTimeMillis()) : ViewModel() { - private val store = Store(TextStoryPostCreationState()) + private val store = RxStore(TextStoryPostCreationState()) private val textFontSubject: Subject = BehaviorSubject.create() private val temporaryBodySubject: Subject = BehaviorSubject.createDefault("") private val disposables = CompositeDisposable() - private val internalTypeface = MutableLiveData() + private val internalTypeface = BehaviorProcessor.create() - val state: LiveData = store.stateLiveData - val typeface: LiveData = internalTypeface + val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + val typeface: Flowable = internalTypeface.observeOn(AndroidSchedulers.mainThread()) init { textFontSubject.onNext(store.state.textFont) val scriptGuess = temporaryBodySubject.observeOn(Schedulers.io()).map { TextToScript.guessScript(it) } - Observable.combineLatest(textFontSubject, scriptGuess, ::Pair) + disposables += Observable.combineLatest(textFontSubject, scriptGuess, ::Pair) .observeOn(Schedulers.io()) .distinctUntilChanged() .switchMapSingle { (textFont, script) -> TypefaceCache.get(ApplicationDependencies.getApplication(), textFont, script) } .subscribeOn(Schedulers.io()) .subscribe { - internalTypeface.postValue(it) + internalTypeface.onNext(it) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt index 525382305f..b676ddb558 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.EditText import androidx.constraintlayout.widget.Group import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import org.thoughtcrime.securesms.R @@ -30,11 +31,7 @@ class TextStoryPostLinkEntryFragment : KeyboardEntryDialogFragment( factoryProducer = { LinkPreviewViewModel.Factory(LinkPreviewRepository(), true) } ) - private val viewModel: TextStoryPostCreationViewModel by viewModels( - ownerProducer = { - requireActivity() - } - ) + private val viewModel: TextStoryPostCreationViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { input = view.findViewById(R.id.input) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt index 207cc30b30..9500ed027b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt @@ -25,6 +25,8 @@ import androidx.core.widget.doOnTextChanged import androidx.fragment.app.viewModels import androidx.transition.TransitionManager import com.airbnb.lottie.SimpleColorFilter +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations @@ -52,6 +54,8 @@ class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment( } ) + private val lifecycleDisposable = LifecycleDisposable() + private lateinit var scene: ConstraintLayout private lateinit var input: EditText private lateinit var confirmButton: View @@ -213,11 +217,13 @@ class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment( } private fun initializeViewModel() { - viewModel.typeface.observe(viewLifecycleOwner) { typeface -> + lifecycleDisposable.bindTo(viewLifecycleOwner) + + lifecycleDisposable += viewModel.typeface.subscribeBy { typeface -> input.typeface = typeface } - viewModel.state.observe(viewLifecycleOwner) { state -> + lifecycleDisposable += viewModel.state.subscribeBy { state -> input.setTextColor(state.textForegroundColor) input.setHintTextColor(state.textForegroundColor) diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java index 86df41c31a..974e893644 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java @@ -149,15 +149,15 @@ public class ShareInterstitialActivity extends PassphraseRequiredActivity { linkPreviewViewModel.getLinkPreviewState().observe(this, linkPreviewState -> { preview.setVisibility(View.VISIBLE); - if (linkPreviewState.getError() != null) { - preview.setNoPreview(linkPreviewState.getError()); + if (linkPreviewState.error != null) { + preview.setNoPreview(linkPreviewState.error); viewModel.onLinkPreviewChanged(null); - } else if (linkPreviewState.isLoading()) { + } else if (linkPreviewState.isLoading) { preview.setLoading(); viewModel.onLinkPreviewChanged(null); - } else if (linkPreviewState.getLinkPreview().isPresent()) { - preview.setLinkPreview(GlideApp.with(this), linkPreviewState.getLinkPreview().get(), true); - viewModel.onLinkPreviewChanged(linkPreviewState.getLinkPreview().get()); + } else if (linkPreviewState.linkPreview.isPresent()) { + preview.setLinkPreview(GlideApp.with(this), linkPreviewState.linkPreview.get(), true); + viewModel.onLinkPreviewChanged(linkPreviewState.linkPreview.get()); } else if (!linkPreviewState.hasLinks()) { preview.setVisibility(View.GONE); viewModel.onLinkPreviewChanged(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt index 328eaf88f8..44d5823079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt @@ -1,11 +1,16 @@ package org.thoughtcrime.securesms.util +import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.savedstate.SavedStateRegistryOwner /** * Simplifies [ViewModel] creation by providing default implementations of [ViewModelProvider.Factory] @@ -28,6 +33,25 @@ class ViewModelFactory(private val create: () -> MODEL) : Vie } } +class SavedStateViewModelFactory( + private val create: (SavedStateHandle) -> MODEL, + registryOwner: SavedStateRegistryOwner +) : AbstractSavedStateViewModelFactory(registryOwner, null) { + @Suppress("UNCHECKED_CAST") + override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { + return create(handle) as T + } + + companion object { + fun factoryProducer( + create: (SavedStateHandle) -> MODEL, + registryOwnerProducer: () -> SavedStateRegistryOwner + ): () -> ViewModelProvider.Factory { + return { SavedStateViewModelFactory(create, registryOwnerProducer()) } + } + } +} + @MainThread inline fun Fragment.viewModel( noinline create: () -> VM @@ -37,6 +61,33 @@ inline fun Fragment.viewModel( ) } +@MainThread +inline fun Fragment.savedStateViewModel( + noinline create: (SavedStateHandle) -> VM +): Lazy { + return viewModels( + factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { this } + ) +} + +@MainThread +inline fun Fragment.activitySavedStateViewModel( + noinline create: (SavedStateHandle) -> VM +): Lazy { + return viewModels( + factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { requireActivity() } + ) +} + +@MainThread +inline fun ComponentActivity.savedStateViewModel( + noinline create: (SavedStateHandle) -> VM +): Lazy { + return viewModels( + factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { this } + ) +} + @MainThread inline fun Fragment.activityViewModel( noinline create: () -> VM