mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 00:29:11 +01:00
Move all files to natural position.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
public class Link {
|
||||
|
||||
private final String url;
|
||||
private final int position;
|
||||
|
||||
public Link(String url, int position) {
|
||||
this.url = url;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class LinkPreview {
|
||||
|
||||
@JsonProperty
|
||||
private final String url;
|
||||
|
||||
@JsonProperty
|
||||
private final String title;
|
||||
|
||||
@JsonProperty
|
||||
private final AttachmentId attachmentId;
|
||||
|
||||
@JsonIgnore
|
||||
private final Optional<Attachment> thumbnail;
|
||||
|
||||
public LinkPreview(@NonNull String url, @NonNull String title, @NonNull DatabaseAttachment thumbnail) {
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.thumbnail = Optional.of(thumbnail);
|
||||
this.attachmentId = thumbnail.getAttachmentId();
|
||||
}
|
||||
|
||||
public LinkPreview(@NonNull String url, @NonNull String title, @NonNull Optional<Attachment> thumbnail) {
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.thumbnail = thumbnail;
|
||||
this.attachmentId = null;
|
||||
}
|
||||
|
||||
public LinkPreview(@JsonProperty("url") @NonNull String url,
|
||||
@JsonProperty("title") @NonNull String title,
|
||||
@JsonProperty("attachmentId") @Nullable AttachmentId attachmentId)
|
||||
{
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
this.attachmentId = attachmentId;
|
||||
this.thumbnail = Optional.absent();
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public Optional<Attachment> getThumbnail() {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
public @Nullable AttachmentId getAttachmentId() {
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
public String serialize() throws IOException {
|
||||
return JsonUtils.toJson(this);
|
||||
}
|
||||
|
||||
public static LinkPreview deserialize(@NonNull String serialized) throws IOException {
|
||||
return JsonUtils.fromJson(serialized, LinkPreview.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class LinkPreviewDomains {
|
||||
public static final String STICKERS = "signal.org";
|
||||
|
||||
public static final Set<String> LINKS = new HashSet<>(Arrays.asList(
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"m.youtube.com",
|
||||
"youtu.be",
|
||||
"reddit.com",
|
||||
"www.reddit.com",
|
||||
"m.reddit.com",
|
||||
"imgur.com",
|
||||
"www.imgur.com",
|
||||
"m.imgur.com",
|
||||
"instagram.com",
|
||||
"www.instagram.com",
|
||||
"m.instagram.com",
|
||||
"pinterest.com",
|
||||
"www.pinterest.com",
|
||||
"pin.it"
|
||||
));
|
||||
|
||||
public static final Set<String> IMAGES = new HashSet<>(Arrays.asList(
|
||||
"ytimg.com",
|
||||
"cdninstagram.com",
|
||||
"fbcdn.net",
|
||||
"redd.it",
|
||||
"imgur.com",
|
||||
"pinimg.com",
|
||||
"giphy.com"
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.Html;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.net.CallRequestController;
|
||||
import org.thoughtcrime.securesms.net.CompositeRequestController;
|
||||
import org.thoughtcrime.securesms.net.ContentProxySafetyInterceptor;
|
||||
import org.thoughtcrime.securesms.net.ContentProxySelector;
|
||||
import org.thoughtcrime.securesms.net.RequestController;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.stickers.StickerRemoteUri;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.InvalidMessageException;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest.StickerInfo;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.CacheControl;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class LinkPreviewRepository {
|
||||
|
||||
private static final String TAG = LinkPreviewRepository.class.getSimpleName();
|
||||
|
||||
private static final CacheControl NO_CACHE = new CacheControl.Builder().noCache().build();
|
||||
|
||||
private final OkHttpClient client;
|
||||
|
||||
public LinkPreviewRepository() {
|
||||
this.client = new OkHttpClient.Builder()
|
||||
.proxySelector(new ContentProxySelector())
|
||||
.addNetworkInterceptor(new ContentProxySafetyInterceptor())
|
||||
.cache(null)
|
||||
.build();
|
||||
}
|
||||
|
||||
RequestController getLinkPreview(@NonNull Context context, @NonNull String url, @NonNull Callback<Optional<LinkPreview>> callback) {
|
||||
CompositeRequestController compositeController = new CompositeRequestController();
|
||||
|
||||
if (!LinkPreviewUtil.isWhitelistedLinkUrl(url)) {
|
||||
Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain.");
|
||||
callback.onComplete(Optional.absent());
|
||||
return compositeController;
|
||||
}
|
||||
|
||||
RequestController metadataController;
|
||||
|
||||
if (StickerUrl.isValidShareLink(url)) {
|
||||
metadataController = fetchStickerPackLinkPreview(context, url, callback);
|
||||
} else {
|
||||
metadataController = fetchMetadata(url, metadata -> {
|
||||
if (metadata.isEmpty()) {
|
||||
callback.onComplete(Optional.absent());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.getImageUrl().isPresent()) {
|
||||
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().get(), Optional.absent())));
|
||||
return;
|
||||
}
|
||||
|
||||
RequestController imageController = fetchThumbnail(context, metadata.getImageUrl().get(), attachment -> {
|
||||
if (!metadata.getTitle().isPresent() && !attachment.isPresent()) {
|
||||
callback.onComplete(Optional.absent());
|
||||
} else {
|
||||
callback.onComplete(Optional.of(new LinkPreview(url, metadata.getTitle().or(""), attachment)));
|
||||
}
|
||||
});
|
||||
|
||||
compositeController.addController(imageController);
|
||||
});
|
||||
}
|
||||
|
||||
compositeController.addController(metadataController);
|
||||
return compositeController;
|
||||
}
|
||||
|
||||
private @NonNull RequestController fetchMetadata(@NonNull String url, Callback<Metadata> callback) {
|
||||
Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build());
|
||||
|
||||
call.enqueue(new okhttp3.Callback() {
|
||||
@Override
|
||||
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
||||
Log.w(TAG, "Request failed.", e);
|
||||
callback.onComplete(Metadata.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
||||
if (!response.isSuccessful()) {
|
||||
Log.w(TAG, "Non-successful response. Code: " + response.code());
|
||||
callback.onComplete(Metadata.empty());
|
||||
return;
|
||||
} else if (response.body() == null) {
|
||||
Log.w(TAG, "No response body.");
|
||||
callback.onComplete(Metadata.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
String body = response.body().string();
|
||||
Optional<String> title = getProperty(body, "title");
|
||||
Optional<String> imageUrl = getProperty(body, "image");
|
||||
|
||||
if (imageUrl.isPresent() && !LinkPreviewUtil.isWhitelistedMediaUrl(imageUrl.get())) {
|
||||
Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping.");
|
||||
imageUrl = Optional.absent();
|
||||
}
|
||||
|
||||
callback.onComplete(new Metadata(title, imageUrl));
|
||||
}
|
||||
});
|
||||
|
||||
return new CallRequestController(call);
|
||||
}
|
||||
|
||||
private @NonNull RequestController fetchThumbnail(@NonNull Context context, @NonNull String imageUrl, @NonNull Callback<Optional<Attachment>> callback) {
|
||||
FutureTarget<Bitmap> bitmapFuture = GlideApp.with(context).asBitmap()
|
||||
.load(new ChunkedImageUrl(imageUrl))
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.centerInside()
|
||||
.submit(1024, 1024);
|
||||
|
||||
RequestController controller = () -> bitmapFuture.cancel(false);
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
Bitmap bitmap = bitmapFuture.get();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
|
||||
|
||||
byte[] bytes = baos.toByteArray();
|
||||
Uri uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory();
|
||||
Optional<Attachment> thumbnail = Optional.of(new UriAttachment(uri,
|
||||
uri,
|
||||
MediaUtil.IMAGE_JPEG,
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_STARTED,
|
||||
bytes.length,
|
||||
bitmap.getWidth(),
|
||||
bitmap.getHeight(),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
callback.onComplete(thumbnail);
|
||||
} catch (CancellationException | ExecutionException | InterruptedException e) {
|
||||
controller.cancel();
|
||||
callback.onComplete(Optional.absent());
|
||||
} finally {
|
||||
bitmapFuture.cancel(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () -> bitmapFuture.cancel(true);
|
||||
}
|
||||
|
||||
private @NonNull Optional<String> getProperty(@NonNull String searchText, @NonNull String property) {
|
||||
Pattern pattern = Pattern.compile("<\\s*meta\\s+property\\s*=\\s*\"\\s*og:" + property + "\\s*\"\\s+[^>]*content\\s*=\\s*\"(.*?)\"[^>]*/?\\s*>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(searchText);
|
||||
|
||||
if (matcher.find()) {
|
||||
String text = Html.fromHtml(matcher.group(1)).toString();
|
||||
return TextUtils.isEmpty(text) ? Optional.absent() : Optional.of(text);
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
private RequestController fetchStickerPackLinkPreview(@NonNull Context context,
|
||||
@NonNull String packUrl,
|
||||
@NonNull Callback<Optional<LinkPreview>> callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
Pair<String, String> stickerParams = StickerUrl.parseShareLink(packUrl).or(new Pair<>("", ""));
|
||||
String packIdString = stickerParams.first();
|
||||
String packKeyString = stickerParams.second();
|
||||
byte[] packIdBytes = Hex.fromStringCondensed(packIdString);
|
||||
byte[] packKeyBytes = Hex.fromStringCondensed(packKeyString);
|
||||
|
||||
SignalServiceMessageReceiver receiver = ApplicationDependencies.getSignalServiceMessageReceiver();
|
||||
SignalServiceStickerManifest manifest = receiver.retrieveStickerManifest(packIdBytes, packKeyBytes);
|
||||
|
||||
String title = manifest.getTitle().or(manifest.getAuthor()).or("");
|
||||
Optional<StickerInfo> firstSticker = Optional.fromNullable(manifest.getStickers().size() > 0 ? manifest.getStickers().get(0) : null);
|
||||
Optional<StickerInfo> cover = manifest.getCover().or(firstSticker);
|
||||
|
||||
if (cover.isPresent()) {
|
||||
Bitmap bitmap = GlideApp.with(context).asBitmap()
|
||||
.load(new StickerRemoteUri(packIdString, packKeyString, cover.get().getId()))
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.centerInside()
|
||||
.submit(512, 512)
|
||||
.get();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
bitmap.compress(Bitmap.CompressFormat.WEBP, 80, baos);
|
||||
|
||||
byte[] bytes = baos.toByteArray();
|
||||
Uri uri = BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory();
|
||||
Optional<Attachment> thumbnail = Optional.of(new UriAttachment(uri,
|
||||
uri,
|
||||
MediaUtil.IMAGE_WEBP,
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_STARTED,
|
||||
bytes.length,
|
||||
bitmap.getWidth(),
|
||||
bitmap.getHeight(),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
callback.onComplete(Optional.of(new LinkPreview(packUrl, title, thumbnail)));
|
||||
} else {
|
||||
callback.onComplete(Optional.absent());
|
||||
}
|
||||
} catch (IOException | InvalidMessageException | ExecutionException | InterruptedException e) {
|
||||
Log.w(TAG, "Failed to fetch sticker pack link preview.");
|
||||
callback.onComplete(Optional.absent());
|
||||
}
|
||||
});
|
||||
|
||||
return () -> Log.i(TAG, "Cancelled sticker pack link preview fetch -- no effect.");
|
||||
}
|
||||
|
||||
private static class Metadata {
|
||||
private final Optional<String> title;
|
||||
private final Optional<String> imageUrl;
|
||||
|
||||
Metadata(Optional<String> title, Optional<String> imageUrl) {
|
||||
this.title = title;
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
static Metadata empty() {
|
||||
return new Metadata(Optional.absent(), Optional.absent());
|
||||
}
|
||||
|
||||
Optional<String> getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
Optional<String> getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return !title.isPresent() && !imageUrl.isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback<T> {
|
||||
void onComplete(@NonNull T result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.URLSpan;
|
||||
import android.text.util.Linkify;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
public final class LinkPreviewUtil {
|
||||
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$");
|
||||
private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$");
|
||||
private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$");
|
||||
|
||||
/**
|
||||
* @return All whitelisted URLs in the source text.
|
||||
*/
|
||||
public static @NonNull List<Link> findWhitelistedUrls(@NonNull String text) {
|
||||
SpannableString spannable = new SpannableString(text);
|
||||
boolean found = Linkify.addLinks(spannable, Linkify.WEB_URLS);
|
||||
|
||||
if (!found) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class))
|
||||
.map(span -> new Link(span.getURL(), spannable.getSpanStart(span)))
|
||||
.filter(link -> isWhitelistedLinkUrl(link.getUrl()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the host is present in the link whitelist.
|
||||
*/
|
||||
public static boolean isWhitelistedLinkUrl(@Nullable String linkUrl) {
|
||||
if (linkUrl == null) return false;
|
||||
if (StickerUrl.isValidShareLink(linkUrl)) return true;
|
||||
|
||||
HttpUrl url = HttpUrl.parse(linkUrl);
|
||||
return url != null &&
|
||||
!TextUtils.isEmpty(url.scheme()) &&
|
||||
"https".equals(url.scheme()) &&
|
||||
LinkPreviewDomains.LINKS.contains(url.host()) &&
|
||||
isLegalUrl(linkUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the top-level domain is present in the media whitelist.
|
||||
*/
|
||||
public static boolean isWhitelistedMediaUrl(@Nullable String mediaUrl) {
|
||||
if (mediaUrl == null) return false;
|
||||
|
||||
HttpUrl url = HttpUrl.parse(mediaUrl);
|
||||
return url != null &&
|
||||
!TextUtils.isEmpty(url.scheme()) &&
|
||||
"https".equals(url.scheme()) &&
|
||||
LinkPreviewDomains.IMAGES.contains(url.topPrivateDomain()) &&
|
||||
isLegalUrl(mediaUrl);
|
||||
}
|
||||
|
||||
public static boolean isLegalUrl(@NonNull String url) {
|
||||
Matcher matcher = DOMAIN_PATTERN.matcher(url);
|
||||
|
||||
if (matcher.matches()) {
|
||||
String domain = matcher.group(2);
|
||||
String cleanedDomain = domain.replaceAll("\\.", "");
|
||||
|
||||
return ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() ||
|
||||
ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.thoughtcrime.securesms.linkpreview;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.net.RequestController;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
public class LinkPreviewViewModel extends ViewModel {
|
||||
|
||||
private final LinkPreviewRepository repository;
|
||||
private final MutableLiveData<LinkPreviewState> linkPreviewState;
|
||||
|
||||
private String activeUrl;
|
||||
private RequestController activeRequest;
|
||||
private boolean userCanceled;
|
||||
private Debouncer debouncer;
|
||||
|
||||
private LinkPreviewViewModel(@NonNull LinkPreviewRepository repository) {
|
||||
this.repository = repository;
|
||||
this.linkPreviewState = new MutableLiveData<>();
|
||||
this.debouncer = new Debouncer(250);
|
||||
}
|
||||
|
||||
public LiveData<LinkPreviewState> getLinkPreviewState() {
|
||||
return linkPreviewState;
|
||||
}
|
||||
|
||||
public boolean hasLinkPreview() {
|
||||
return linkPreviewState.getValue() != null && linkPreviewState.getValue().getLinkPreview().isPresent();
|
||||
}
|
||||
|
||||
public @NonNull List<LinkPreview> getActiveLinkPreviews() {
|
||||
final LinkPreviewState state = linkPreviewState.getValue();
|
||||
|
||||
if (state == null || !state.getLinkPreview().isPresent()) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return Collections.singletonList(state.getLinkPreview().get());
|
||||
}
|
||||
}
|
||||
|
||||
public void onTextChanged(@NonNull Context context, @NonNull String text, int cursorStart, int cursorEnd) {
|
||||
debouncer.publish(() -> {
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
userCanceled = false;
|
||||
}
|
||||
|
||||
if (userCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Link> links = LinkPreviewUtil.findWhitelistedUrls(text);
|
||||
Optional<Link> link = links.isEmpty() ? Optional.absent() : Optional.of(links.get(0));
|
||||
|
||||
if (link.isPresent() && link.get().getUrl().equals(activeUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequest != null) {
|
||||
activeRequest.cancel();
|
||||
activeRequest = null;
|
||||
}
|
||||
|
||||
if (!link.isPresent() || !isCursorPositionValid(text, link.get(), cursorStart, cursorEnd)) {
|
||||
activeUrl = null;
|
||||
linkPreviewState.setValue(LinkPreviewState.forEmpty());
|
||||
return;
|
||||
}
|
||||
|
||||
linkPreviewState.setValue(LinkPreviewState.forLoading());
|
||||
|
||||
activeUrl = link.get().getUrl();
|
||||
activeRequest = repository.getLinkPreview(context, link.get().getUrl(), lp -> {
|
||||
Util.runOnMain(() -> {
|
||||
if (!userCanceled) {
|
||||
linkPreviewState.setValue(LinkPreviewState.forPreview(lp));
|
||||
}
|
||||
activeRequest = null;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void onUserCancel() {
|
||||
if (activeRequest != null) {
|
||||
activeRequest.cancel();
|
||||
activeRequest = null;
|
||||
}
|
||||
|
||||
userCanceled = true;
|
||||
activeUrl = null;
|
||||
|
||||
debouncer.clear();
|
||||
linkPreviewState.setValue(LinkPreviewState.forEmpty());
|
||||
}
|
||||
|
||||
public void onEnabled() {
|
||||
userCanceled = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
if (activeRequest != null) {
|
||||
activeRequest.cancel();
|
||||
}
|
||||
|
||||
debouncer.clear();
|
||||
}
|
||||
|
||||
private boolean isCursorPositionValid(@NonNull String text, @NonNull Link link, int cursorStart, int cursorEnd) {
|
||||
if (cursorStart != cursorEnd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.endsWith(link.getUrl()) && cursorStart == link.getPosition() + link.getUrl().length()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cursorStart < link.getPosition() || cursorStart > link.getPosition() + link.getUrl().length();
|
||||
}
|
||||
|
||||
public static class LinkPreviewState {
|
||||
private final boolean isLoading;
|
||||
private final Optional<LinkPreview> linkPreview;
|
||||
|
||||
private LinkPreviewState(boolean isLoading, Optional<LinkPreview> linkPreview) {
|
||||
this.isLoading = isLoading;
|
||||
this.linkPreview = linkPreview;
|
||||
}
|
||||
|
||||
private static LinkPreviewState forLoading() {
|
||||
return new LinkPreviewState(true, Optional.absent());
|
||||
}
|
||||
|
||||
private static LinkPreviewState forPreview(@NonNull Optional<LinkPreview> linkPreview) {
|
||||
return new LinkPreviewState(false, linkPreview);
|
||||
}
|
||||
|
||||
private static LinkPreviewState forEmpty() {
|
||||
return new LinkPreviewState(false, Optional.absent());
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
public Optional<LinkPreview> getLinkPreview() {
|
||||
return linkPreview;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
|
||||
private final LinkPreviewRepository repository;
|
||||
|
||||
public Factory(@NonNull LinkPreviewRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return modelClass.cast(new LinkPreviewViewModel(repository));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user