package org.thoughtcrime.securesms.linkpreview; import android.content.Context; import android.graphics.Bitmap; import android.net.Uri; import android.support.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.database.AttachmentDatabase; 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.ContentProxySelector; import org.thoughtcrime.securesms.net.RequestController; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.whispersystems.libsignal.util.guava.Optional; 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()) .cache(null) .build(); } RequestController getLinkPreview(@NonNull Context context, @NonNull String url, @NonNull Callback> 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 = 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 callback) { Call call = client.newCall(new Request.Builder().url(url).cacheControl(NO_CACHE).build()); call.enqueue(new okhttp3.Callback() { @Override public void onFailure(Call call, IOException e) { Log.w(TAG, "Request failed.", e); callback.onComplete(Metadata.empty()); } @Override public void onResponse(Call call, 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 title = getProperty(body, "title"); Optional 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> callback) { FutureTarget bitmapFuture = GlideApp.with(context).asBitmap() .load(new ChunkedImageUrl(imageUrl)) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) .centerInside() .submit(1024, 1024); RequestController controller = () -> bitmapFuture.cancel(false); SignalExecutors.IO.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 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)); 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 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 static class Metadata { private final Optional title; private final Optional imageUrl; Metadata(Optional title, Optional imageUrl) { this.title = title; this.imageUrl = imageUrl; } static Metadata empty() { return new Metadata(Optional.absent(), Optional.absent()); } Optional getTitle() { return title; } Optional getImageUrl() { return imageUrl; } boolean isEmpty() { return !title.isPresent() && !imageUrl.isPresent(); } } interface Callback { void onComplete(@NonNull T result); } }