Add SavedStateHandle support to LinkPreviewViewModelV2.

This commit is contained in:
Alex Hart
2023-09-29 08:25:17 -04:00
committed by GitHub
parent f18a03ee6d
commit d46daed49a
21 changed files with 500 additions and 143 deletions

View File

@@ -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<Attachment> CREATOR = AttachmentCreator.INSTANCE;
@Nullable
public abstract Uri getUri();

View File

@@ -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<Attachment> {
enum class Subclass(val clazz: Class<out Attachment>, 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<Attachment?> = arrayOfNulls(size)
}

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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<AudioHash> 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 {

View File

@@ -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 {

View File

@@ -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<TransformProperties> 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);
}

View File

@@ -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<LinkPreview> CREATOR = new Creator<LinkPreview>() {
@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;
}

View File

@@ -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> linkPreview;
private final LinkPreviewRepository.Error error;
private LinkPreviewState(@Nullable String activeUrlForError,
boolean isLoading,
boolean hasLinks,
Optional<LinkPreview> 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<LinkPreview> getLinkPreview() {
return linkPreview;
}
public @Nullable LinkPreviewRepository.Error getError() {
return error;
}
public boolean hasContent() {
return isLoading || hasLinks;
}
}

View File

@@ -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<LinkPreview> = 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
)
}
}
}

View File

@@ -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);
}

View File

@@ -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>(LinkPreviewState.forNoLinks())
val linkPreviewState: Flowable<LinkPreviewState> = 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<LinkPreviewState> = 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) }
}

View File

@@ -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()

View File

@@ -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<TextFont> = BehaviorSubject.create()
private val temporaryBodySubject: Subject<String> = BehaviorSubject.createDefault("")
private val disposables = CompositeDisposable()
private val internalTypeface = MutableLiveData<Typeface>()
private val internalTypeface = BehaviorProcessor.create<Typeface>()
val state: LiveData<TextStoryPostCreationState> = store.stateLiveData
val typeface: LiveData<Typeface> = internalTypeface
val state: Flowable<TextStoryPostCreationState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val typeface: Flowable<Typeface> = 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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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);

View File

@@ -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<MODEL : ViewModel>(private val create: () -> MODEL) : Vie
}
}
class SavedStateViewModelFactory<MODEL : ViewModel>(
private val create: (SavedStateHandle) -> MODEL,
registryOwner: SavedStateRegistryOwner
) : AbstractSavedStateViewModelFactory(registryOwner, null) {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return create(handle) as T
}
companion object {
fun <MODEL : ViewModel> factoryProducer(
create: (SavedStateHandle) -> MODEL,
registryOwnerProducer: () -> SavedStateRegistryOwner
): () -> ViewModelProvider.Factory {
return { SavedStateViewModelFactory(create, registryOwnerProducer()) }
}
}
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModel(
noinline create: () -> VM
@@ -37,6 +61,33 @@ inline fun <reified VM : ViewModel> Fragment.viewModel(
)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.savedStateViewModel(
noinline create: (SavedStateHandle) -> VM
): Lazy<VM> {
return viewModels(
factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { this }
)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.activitySavedStateViewModel(
noinline create: (SavedStateHandle) -> VM
): Lazy<VM> {
return viewModels(
factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { requireActivity() }
)
}
@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.savedStateViewModel(
noinline create: (SavedStateHandle) -> VM
): Lazy<VM> {
return viewModels(
factoryProducer = SavedStateViewModelFactory.factoryProducer(create) { this }
)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.activityViewModel(
noinline create: () -> VM