/******************************************************************************* * This file is part of RedReader. * * RedReader is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * RedReader is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RedReader. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package org.quantumbadger.redreader.common; import android.Manifest; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Handler; import android.os.Parcelable; import android.preference.PreferenceManager; import android.support.v7.app.AppCompatActivity; import android.text.ClipboardManager; import android.util.Log; import org.quantumbadger.redreader.R; import org.quantumbadger.redreader.activities.*; import org.quantumbadger.redreader.cache.CacheRequest; import org.quantumbadger.redreader.fragments.UserProfileDialog; import org.quantumbadger.redreader.image.*; import org.quantumbadger.redreader.reddit.things.RedditPost; import org.quantumbadger.redreader.reddit.url.RedditURLParser; import java.util.ArrayList; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; public class LinkHandler { public static final Pattern youtubeDotComPattern = Pattern.compile("^https?://[\\.\\w]*youtube\\.\\w+/.*"), youtuDotBePattern = Pattern.compile("^https?://[\\.\\w]*youtu\\.be/([A-Za-z0-9\\-_]+)(\\?.*|).*"), vimeoPattern = Pattern.compile("^https?://[\\.\\w]*vimeo\\.\\w+/.*"), googlePlayPattern = Pattern.compile("^https?://[\\.\\w]*play\\.google\\.\\w+/.*"); public enum LinkAction { SHARE(R.string.action_share), COPY_URL(R.string.action_copy_link), SHARE_IMAGE(R.string.action_share_image), SAVE_IMAGE(R.string.action_save), EXTERNAL(R.string.action_external); public final int descriptionResId; LinkAction(final int descriptionResId){ this.descriptionResId = descriptionResId; } } public static void onLinkClicked(AppCompatActivity activity, String url) { onLinkClicked(activity, url, false); } public static void onLinkClicked(AppCompatActivity activity, String url, boolean forceNoImage) { onLinkClicked(activity, url, forceNoImage, null); } public static void onLinkClicked( final AppCompatActivity activity, String url, final boolean forceNoImage, final RedditPost post) { onLinkClicked(activity, url, forceNoImage, post, null, 0); } public static void onLinkClicked( final AppCompatActivity activity, String url, final boolean forceNoImage, final RedditPost post, final ImgurAPI.AlbumInfo albumInfo, final int albumImageIndex) { onLinkClicked(activity, url, forceNoImage, post, albumInfo, albumImageIndex, false); } public static void onLinkClicked( final AppCompatActivity activity, String url, final boolean forceNoImage, final RedditPost post, final ImgurAPI.AlbumInfo albumInfo, final int albumImageIndex, final boolean fromExternalIntent) { final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity); if(url.startsWith("rr://")) { final Uri rrUri = Uri.parse(url); if(rrUri.getAuthority().equals("msg")) { new Handler().post(new Runnable() { @Override public void run() { final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(rrUri.getQueryParameter("title")); builder.setMessage(rrUri.getQueryParameter("message")); AlertDialog alert = builder.create(); alert.show(); } }); return; } } if(url.startsWith("r/") || url.startsWith("u/")) { url = "/" + url; } if(url.startsWith("/")) { url = "https://reddit.com" + url; } if(!url.contains("://")) { url = "http://" + url; } if(!forceNoImage && isProbablyAnImage(url)) { final Intent intent = new Intent(activity, ImageViewActivity.class); intent.setData(Uri.parse(url)); intent.putExtra("post", post); if(albumInfo != null) { intent.putExtra("album", albumInfo.id); intent.putExtra("albumImageIndex", albumImageIndex); } activity.startActivity(intent); return; } if(!forceNoImage && imgurAlbumPattern.matcher(url).matches()) { final PrefsUtility.AlbumViewMode albumViewMode = PrefsUtility.pref_behaviour_albumview_mode(activity, sharedPreferences); switch(albumViewMode) { case INTERNAL_LIST: { final Intent intent = new Intent(activity, AlbumListingActivity.class); intent.setData(Uri.parse(url)); intent.putExtra("post", post); activity.startActivity(intent); return; } case INTERNAL_BROWSER: { final Intent intent = new Intent(activity, WebViewActivity.class); intent.putExtra("url", url); intent.putExtra("post", post); activity.startActivity(intent); return; } case EXTERNAL_BROWSER: { openWebBrowser(activity, Uri.parse(url), fromExternalIntent); return; } } } final RedditURLParser.RedditURL redditURL = RedditURLParser.parse(Uri.parse(url)); if(redditURL != null) { switch(redditURL.pathType()) { case RedditURLParser.SUBREDDIT_POST_LISTING_URL: case RedditURLParser.MULTIREDDIT_POST_LISTING_URL: case RedditURLParser.USER_POST_LISTING_URL: case RedditURLParser.UNKNOWN_POST_LISTING_URL: { final Intent intent = new Intent(activity, PostListingActivity.class); intent.setData(redditURL.generateJsonUri()); activity.startActivityForResult(intent, 1); return; } case RedditURLParser.POST_COMMENT_LISTING_URL: case RedditURLParser.USER_COMMENT_LISTING_URL: { final Intent intent = new Intent(activity, CommentListingActivity.class); intent.setData(redditURL.generateJsonUri()); activity.startActivityForResult(intent, 1); return; } case RedditURLParser.USER_PROFILE_URL: { UserProfileDialog.newInstance(redditURL.asUserProfileURL().username).show(activity.getSupportFragmentManager(), null); return; } } } // Use a browser if(!PrefsUtility.pref_behaviour_useinternalbrowser(activity, sharedPreferences)) { if(openWebBrowser(activity, Uri.parse(url), fromExternalIntent)) { return; } } if(youtubeDotComPattern.matcher(url).matches() || vimeoPattern.matcher(url).matches() || googlePlayPattern.matcher(url).matches()) { if(openWebBrowser(activity, Uri.parse(url), fromExternalIntent)) { return; } } final Matcher youtuDotBeMatcher = youtuDotBePattern.matcher(url); if(youtuDotBeMatcher.find() && youtuDotBeMatcher.group(1) != null) { final String youtuBeUrl = "http://youtube.com/watch?v=" + youtuDotBeMatcher.group(1) + (youtuDotBeMatcher.group(2).length() > 0 ? "&" + youtuDotBeMatcher.group(2).substring(1) : ""); if(openWebBrowser(activity, Uri.parse(youtuBeUrl), fromExternalIntent)) { return; } } final Intent intent = new Intent(activity, WebViewActivity.class); intent.putExtra("url", url); intent.putExtra("post", post); activity.startActivity(intent); } public static void onLinkLongClicked(AppCompatActivity activity, String uri){ onLinkLongClicked(activity, uri, false); } public static void onLinkLongClicked(final AppCompatActivity activity, final String uri, final boolean forceNoImage) { if (uri == null){ return; } final EnumSet<LinkHandler.LinkAction> itemPref = PrefsUtility.pref_menus_link_context_items(activity, PreferenceManager.getDefaultSharedPreferences(activity)); if (itemPref.isEmpty()) { return; } final ArrayList<LinkMenuItem> menu = new ArrayList<>(); if (itemPref.contains(LinkAction.COPY_URL)) { menu.add(new LinkMenuItem(activity, R.string.action_copy_link, LinkAction.COPY_URL)); } if (itemPref.contains(LinkAction.EXTERNAL)) { menu.add(new LinkMenuItem(activity, R.string.action_external, LinkAction.EXTERNAL)); } if (itemPref.contains(LinkAction.SAVE_IMAGE) && isProbablyAnImage(uri) && !forceNoImage) { menu.add(new LinkMenuItem(activity, R.string.action_save_image, LinkAction.SAVE_IMAGE)); } if (itemPref.contains(LinkAction.SHARE)) { menu.add(new LinkMenuItem(activity, R.string.action_share, LinkAction.SHARE)); } if (itemPref.contains(LinkAction.SHARE_IMAGE) && isProbablyAnImage(uri) && !forceNoImage) { menu.add(new LinkMenuItem(activity, R.string.action_share_image, LinkAction.SHARE_IMAGE)); } final String[] menuText = new String[menu.size()]; for (int i = 0; i < menuText.length; i++) { menuText[i] = menu.get(i).title; } final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setItems(menuText, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { onActionMenuItemSelected(uri, activity, menu.get(which).action); } }); //builder.setNeutralButton(R.string.dialog_cancel, null); final AlertDialog alert = builder.create(); alert.setCanceledOnTouchOutside(true); alert.show(); } public static void onActionMenuItemSelected(String uri, AppCompatActivity activity, LinkAction action){ switch (action){ case SHARE: final Intent mailer = new Intent(Intent.ACTION_SEND); mailer.setType("text/plain"); mailer.putExtra(Intent.EXTRA_TEXT, uri); activity.startActivity(Intent.createChooser(mailer, activity.getString(R.string.action_share))); break; case COPY_URL: ClipboardManager manager = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); manager.setText(uri); break; case EXTERNAL: final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(uri)); activity.startActivity(intent); break; case SHARE_IMAGE: ((BaseActivity)activity).requestPermissionWithCallback(Manifest.permission.WRITE_EXTERNAL_STORAGE, new ShareImageCallback(activity, uri)); break; case SAVE_IMAGE: ((BaseActivity)activity).requestPermissionWithCallback(Manifest.permission.WRITE_EXTERNAL_STORAGE, new SaveImageCallback(activity, uri)); break; } } public static boolean openWebBrowser(AppCompatActivity activity, Uri uri, final boolean fromExternalIntent) { if(!fromExternalIntent) { try { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(uri); activity.startActivity(intent); return true; } catch(Exception e) { General.quickToast(activity, "Failed to open url \"" + uri.toString() + "\" in external browser"); } } else { // We want to make sure we don't just pass this back to ourselves final Intent baseIntent = new Intent(Intent.ACTION_VIEW); baseIntent.setData(uri); final ArrayList<Intent> targetIntents = new ArrayList<>(); for (final ResolveInfo info : activity.getPackageManager().queryIntentActivities(baseIntent, 0)) { final String packageName = info.activityInfo.packageName; Log.i("RRDEBUG", "Considering " + packageName); if (packageName != null && !packageName.startsWith("org.quantumbadger.redreader")) { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(uri); intent.setPackage(packageName); targetIntents.add(intent); } } if(!targetIntents.isEmpty()) { final Intent chooserIntent = Intent.createChooser( targetIntents.remove(0), activity.getString(R.string.open_with)); if(!targetIntents.isEmpty()) { chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, targetIntents.toArray(new Parcelable[]{})); } activity.startActivity(chooserIntent); return true; } } return false; } public static final Pattern imgurPattern = Pattern.compile(".*[^A-Za-z]imgur\\.com/(\\w+).*"), imgurAlbumPattern = Pattern.compile(".*[^A-Za-z]imgur\\.com/(a|gallery)/(\\w+).*"), qkmePattern1 = Pattern.compile(".*[^A-Za-z]qkme\\.me/(\\w+).*"), qkmePattern2 = Pattern.compile(".*[^A-Za-z]quickmeme\\.com/meme/(\\w+).*"), lvmePattern = Pattern.compile(".*[^A-Za-z]livememe\\.com/(\\w+).*"), gfycatPattern = Pattern.compile(".*[^A-Za-z]gfycat\\.com/(\\w+).*"), streamablePattern = Pattern.compile(".*[^A-Za-z]streamable\\.com/(\\w+).*"), reddituploadsPattern = Pattern.compile(".*[^A-Za-z]i\\.reddituploads\\.com/(\\w+).*"), imgflipPattern = Pattern.compile(".*[^A-Za-z]imgflip\\.com/i/(\\w+).*"), makeamemePattern = Pattern.compile(".*[^A-Za-z]makeameme\\.org/meme/([\\w\\-]+).*"); public static boolean isProbablyAnImage(final String url) { { final Matcher matchImgur = imgurPattern.matcher(url); if(matchImgur.find()) { final String imgId = matchImgur.group(1); if(imgId.length() > 2 && !imgId.startsWith("gallery")) { return true; } } } { final Matcher matchGfycat = gfycatPattern.matcher(url); if(matchGfycat.find()) { final String imgId = matchGfycat.group(1); if(imgId.length() > 5) { return true; } } } { final Matcher matchStreamable = streamablePattern.matcher(url); if(matchStreamable.find()) { final String imgId = matchStreamable.group(1); if(imgId.length() > 2) { return true; } } } { final Matcher matchRedditUploads = reddituploadsPattern.matcher(url); if(matchRedditUploads.find()) { final String imgId = matchRedditUploads.group(1); if(imgId.length() > 10) { return true; } } } { final Matcher matchImgflip = imgflipPattern.matcher(url); if(matchImgflip.find()) { final String imgId = matchImgflip.group(1); if(imgId.length() > 3) { return true; } } } { final Matcher matchMakeameme = makeamemePattern.matcher(url); if(matchMakeameme.find()) { final String imgId = matchMakeameme.group(1); if(imgId.length() > 3) { return true; } } } return getImageUrlPatternMatch(url) != null; } private static abstract class ImageInfoRetryListener implements GetImageInfoListener { private final GetImageInfoListener mListener; private ImageInfoRetryListener(final GetImageInfoListener listener) { mListener = listener; } @Override public abstract void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage); @Override public void onSuccess(final ImageInfo info) { mListener.onSuccess(info); } @Override public void onNotAnImage() { mListener.onNotAnImage(); } } public static void getImgurImageInfo( final Context context, final String imgId, final int priority, final int listId, final boolean returnUrlOnFailure, final GetImageInfoListener listener) { Log.i("getImgurImageInfo", "Image " + imgId + ": trying API v3 with auth"); ImgurAPIV3.getImageInfo(context, imgId, priority, listId, true, new ImageInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurImageInfo", "Image " + imgId + ": trying API v3 without auth"); ImgurAPIV3.getImageInfo(context, imgId, priority, listId, false, new ImageInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurImageInfo", "Image " + imgId + ": trying API v2"); ImgurAPI.getImageInfo(context, imgId, priority, listId, new ImageInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurImageInfo", "All API requests failed!"); if(returnUrlOnFailure) { listener.onSuccess(new ImageInfo("https://i.imgur.com/" + imgId + ".jpg", null)); } else { listener.onFailure(type, t, status, readableMessage); } } }); } }); } }); } private static abstract class AlbumInfoRetryListener implements GetAlbumInfoListener { private final GetAlbumInfoListener mListener; private AlbumInfoRetryListener(final GetAlbumInfoListener listener) { mListener = listener; } @Override public abstract void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage); @Override public void onSuccess(final ImgurAPI.AlbumInfo info) { mListener.onSuccess(info); } } public static void getImgurAlbumInfo( final Context context, final String albumId, final int priority, final int listId, final GetAlbumInfoListener listener) { Log.i("getImgurAlbumInfo", "Album " + albumId + ": trying API v3 with auth"); ImgurAPIV3.getAlbumInfo(context, albumId, priority, listId, true, new AlbumInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurAlbumInfo", "Album " + albumId + ": trying API v3 without auth"); ImgurAPIV3.getAlbumInfo(context, albumId, priority, listId, false, new AlbumInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurAlbumInfo", "Album " + albumId + ": trying API v2"); ImgurAPI.getAlbumInfo(context, albumId, priority, listId, new AlbumInfoRetryListener(listener) { @Override public void onFailure(final @CacheRequest.RequestFailureType int type, final Throwable t, final Integer status, final String readableMessage) { Log.i("getImgurImageInfo", "All API requests failed!"); listener.onFailure(type, t, status, readableMessage); } }); } }); } }); } public static void getImageInfo( final Context context, final String url, final int priority, final int listId, final GetImageInfoListener listener) { { final Matcher matchImgur = imgurPattern.matcher(url); if(matchImgur.find()) { final String imgId = matchImgur.group(1); if(imgId.length() > 2 && !imgId.startsWith("gallery")) { getImgurImageInfo(context, imgId, priority, listId, true, listener); return; } } } { final Matcher matchGfycat = gfycatPattern.matcher(url); if(matchGfycat.find()) { final String imgId = matchGfycat.group(1); if(imgId.length() > 5) { GfycatAPI.getImageInfo(context, imgId, priority, listId, listener); return; } } } { final Matcher matchStreamable = streamablePattern.matcher(url); if(matchStreamable.find()) { final String imgId = matchStreamable.group(1); if(imgId.length() > 2) { StreamableAPI.getImageInfo(context, imgId, priority, listId, listener); return; } } } { final Matcher matchRedditUploads = reddituploadsPattern.matcher(url); if(matchRedditUploads.find()) { final String imgId = matchRedditUploads.group(1); if(imgId.length() > 10) { listener.onSuccess(new ImageInfo(url, ImageInfo.MediaType.IMAGE)); return; } } } { final Matcher matchImgflip = imgflipPattern.matcher(url); if(matchImgflip.find()) { final String imgId = matchImgflip.group(1); if(imgId.length() > 3) { final String imageUrl = "https://i.imgflip.com/" + imgId + ".jpg"; listener.onSuccess(new ImageInfo(imageUrl, ImageInfo.MediaType.IMAGE)); return; } } } { final Matcher matchMakeameme = makeamemePattern.matcher(url); if(matchMakeameme.find()) { final String imgId = matchMakeameme.group(1); if(imgId.length() > 3) { final String imageUrl = "https://media.makeameme.org/created/" + imgId + ".jpg"; listener.onSuccess(new ImageInfo(imageUrl, ImageInfo.MediaType.IMAGE)); return; } } } final ImageInfo imageUrlPatternMatch = getImageUrlPatternMatch(url); if(imageUrlPatternMatch != null) { listener.onSuccess(imageUrlPatternMatch); } else { listener.onNotAnImage(); } } private static ImageInfo getImageUrlPatternMatch(final String url) { final String urlLower = General.asciiLowercase(url); final String[] imageExtensions = {".jpg", ".jpeg", ".png"}; final String[] videoExtensions = {".webm", ".mp4", ".h264", ".gifv", ".mkv", ".3gp"}; for(final String ext: imageExtensions) { if(urlLower.endsWith(ext)) { return new ImageInfo(url, ImageInfo.MediaType.IMAGE); } } for(final String ext: videoExtensions) { if(urlLower.endsWith(ext)) { return new ImageInfo(url, ImageInfo.MediaType.VIDEO); } } if(urlLower.endsWith(".gif")) { return new ImageInfo(url, ImageInfo.MediaType.GIF); } if(url.contains("?")) { final String urlBeforeQ = urlLower.split("\\?")[0]; for(final String ext: imageExtensions) { if(urlBeforeQ.endsWith(ext)) { return new ImageInfo(url, ImageInfo.MediaType.IMAGE); } } for(final String ext: videoExtensions) { if(urlBeforeQ.endsWith(ext)) { return new ImageInfo(url, ImageInfo.MediaType.VIDEO); } } if(urlBeforeQ.endsWith(".gif")) { return new ImageInfo(url, ImageInfo.MediaType.GIF); } } final Matcher matchQkme1 = qkmePattern1.matcher(url); if(matchQkme1.find()) { final String imgId = matchQkme1.group(1); if(imgId.length() > 2) { return new ImageInfo(String.format(Locale.US, "http://i.qkme.me/%s.jpg", imgId), ImageInfo.MediaType.IMAGE); } } final Matcher matchQkme2 = qkmePattern2.matcher(url); if(matchQkme2.find()) { final String imgId = matchQkme2.group(1); if (imgId.length() > 2) { return new ImageInfo(String.format(Locale.US, "http://i.qkme.me/%s.jpg", imgId), ImageInfo.MediaType.IMAGE); } } final Matcher matchLvme = lvmePattern.matcher(url); if(matchLvme.find()) { final String imgId = matchLvme.group(1); if (imgId.length() > 2) { return new ImageInfo(String.format(Locale.US, "http://www.livememe.com/%s.jpg", imgId), ImageInfo.MediaType.IMAGE); } } return null; } public static LinkedHashSet<String> computeAllLinks(final String text) { final LinkedHashSet<String> result = new LinkedHashSet<>(); // From http://stackoverflow.com/a/1806161/1526861 // TODO may not handle .co.uk, similar (but should handle .co/.us/.it/etc fine) final Pattern urlPattern = Pattern.compile("\\b((((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)" + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov" + "|mil|biz|info|mobi|name|aero|jobs|museum" + "|travel|[a-z]{2}))(:[\\d]{1,5})?" + "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?" + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?" + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)" + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?" + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*" + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?)\\b"); final Matcher urlMatcher = urlPattern.matcher(text); while(urlMatcher.find()) { result.add(urlMatcher.group(1)); } final Matcher subredditMatcher = Pattern.compile("(?<!\\w)(/?[ru]/\\w+)\\b").matcher(text); while(subredditMatcher.find()) { result.add(subredditMatcher.group(1)); } return result; } private static class LinkMenuItem { public final String title; public final LinkAction action; private LinkMenuItem(Context context, int titleRes, LinkAction action) { this.title = context.getString(titleRes); this.action = action; } } }