| package org.wordpress.android.ui.reader; |
| |
| import android.annotation.SuppressLint; |
| import android.net.Uri; |
| import android.os.Handler; |
| |
| import org.wordpress.android.R; |
| import org.wordpress.android.WordPress; |
| import org.wordpress.android.models.ReaderPost; |
| import org.wordpress.android.models.ReaderPostDiscoverData; |
| import org.wordpress.android.ui.reader.utils.ImageSizeMap; |
| import org.wordpress.android.ui.reader.utils.ImageSizeMap.ImageSize; |
| import org.wordpress.android.ui.reader.utils.ReaderHtmlUtils; |
| import org.wordpress.android.ui.reader.utils.ReaderIframeScanner; |
| import org.wordpress.android.ui.reader.utils.ReaderImageScanner; |
| import org.wordpress.android.ui.reader.utils.ReaderUtils; |
| import org.wordpress.android.ui.reader.views.ReaderWebView; |
| import org.wordpress.android.util.AppLog; |
| import org.wordpress.android.util.DisplayUtils; |
| import org.wordpress.android.util.PhotonUtils; |
| import org.wordpress.android.util.StringUtils; |
| |
| import java.lang.ref.WeakReference; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.Random; |
| import java.util.regex.Pattern; |
| |
| /** |
| * generates and displays the HTML for post detail content - main purpose is to assign the |
| * height/width attributes on image tags to (1) avoid the webView resizing as images are |
| * loaded, and (2) avoid requesting images at a size larger than the display |
| * |
| * important to note that displayed images rely on dp rather than px sizes due to the |
| * fact that WebView "converts CSS pixel values to density-independent pixel values" |
| * http://developer.android.com/guide/webapps/targeting.html |
| */ |
| class ReaderPostRenderer { |
| |
| private final ReaderResourceVars mResourceVars; |
| private final ReaderPost mPost; |
| private final int mMinFullSizeWidthDp; |
| private final int mMinMidSizeWidthDp; |
| private final WeakReference<ReaderWebView> mWeakWebView; |
| |
| private StringBuilder mRenderBuilder; |
| private String mRenderedHtml; |
| private ImageSizeMap mAttachmentSizes; |
| |
| @SuppressLint("SetJavaScriptEnabled") |
| ReaderPostRenderer(ReaderWebView webView, ReaderPost post) { |
| if (webView == null) { |
| throw new IllegalArgumentException("ReaderPostRenderer requires a webView"); |
| } |
| if (post == null) { |
| throw new IllegalArgumentException("ReaderPostRenderer requires a post"); |
| } |
| |
| mPost = post; |
| mWeakWebView = new WeakReference<>(webView); |
| mResourceVars = new ReaderResourceVars(webView.getContext()); |
| |
| mMinFullSizeWidthDp = pxToDp(mResourceVars.fullSizeImageWidthPx / 3); |
| mMinMidSizeWidthDp = mMinFullSizeWidthDp / 2; |
| |
| // enable JavaScript in the webView, otherwise videos and other embedded content won't |
| // work - note that the content is scrubbed on the backend so this is considered safe |
| webView.getSettings().setJavaScriptEnabled(true); |
| } |
| |
| void beginRender() { |
| final Handler handler = new Handler(); |
| mRenderBuilder = new StringBuilder(getPostContent()); |
| |
| new Thread() { |
| @Override |
| public void run() { |
| final boolean hasTiledGallery = hasTiledGallery(mRenderBuilder.toString()); |
| |
| if (!(hasTiledGallery && mResourceVars.isWideDisplay)) { |
| resizeImages(); |
| } |
| |
| resizeIframes(); |
| |
| final String htmlContent = formatPostContentForWebView(mRenderBuilder.toString(), hasTiledGallery, |
| mResourceVars.isWideDisplay); |
| mRenderBuilder = null; |
| handler.post(new Runnable() { |
| @Override |
| public void run() { |
| renderHtmlContent(htmlContent); |
| } |
| }); |
| } |
| }.start(); |
| } |
| |
| public static boolean hasTiledGallery(String text) { |
| // determine whether a tiled-gallery exists in the content |
| return Pattern.compile("tiled-gallery[\\s\"']").matcher(text).find(); |
| } |
| |
| /* |
| * scan the content for images and make sure they're correctly sized for the device |
| */ |
| private void resizeImages() { |
| ReaderHtmlUtils.HtmlScannerListener imageListener = new ReaderHtmlUtils.HtmlScannerListener() { |
| @Override |
| public void onTagFound(String imageTag, String imageUrl) { |
| if (!imageUrl.contains("wpcom-smileys")) { |
| replaceImageTag(imageTag, imageUrl); |
| } |
| } |
| }; |
| ReaderImageScanner scanner = new ReaderImageScanner(mRenderBuilder.toString(), mPost.isPrivate); |
| scanner.beginScan(imageListener); |
| } |
| |
| /* |
| * scan the content for iframes and make sure they're correctly sized for the device |
| */ |
| private void resizeIframes() { |
| ReaderHtmlUtils.HtmlScannerListener iframeListener = new ReaderHtmlUtils.HtmlScannerListener() { |
| @Override |
| public void onTagFound(String tag, String src) { |
| replaceIframeTag(tag, src); |
| } |
| }; |
| ReaderIframeScanner scanner = new ReaderIframeScanner(mRenderBuilder.toString()); |
| scanner.beginScan(iframeListener); |
| } |
| |
| /* |
| * called once the content is ready to be rendered in the webView |
| */ |
| private void renderHtmlContent(final String htmlContent) { |
| mRenderedHtml = htmlContent; |
| |
| // make sure webView is still valid (containing fragment may have been detached) |
| ReaderWebView webView = mWeakWebView.get(); |
| if (webView == null || webView.getContext() == null || webView.isDestroyed()) { |
| AppLog.w(AppLog.T.READER, "reader renderer > webView invalid"); |
| return; |
| } |
| |
| // IMPORTANT: use loadDataWithBaseURL() since loadData() may fail |
| // https://code.google.com/p/android/issues/detail?id=4401 |
| // also important to use null as the baseUrl since onPageFinished |
| // doesn't appear to fire when it's set to an actual url |
| webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null); |
| } |
| |
| /* |
| * called when image scanner finds an image, tries to replace the image tag with one that |
| * has height & width attributes set correctly for the current display, if that fails |
| * replaces it with one that has our 'size-none' class |
| */ |
| private void replaceImageTag(final String imageTag, final String imageUrl) { |
| ImageSize origSize = getImageSize(imageTag, imageUrl); |
| boolean hasWidth = (origSize != null && origSize.width > 0); |
| boolean isFullSize = hasWidth && (origSize.width >= mMinFullSizeWidthDp); |
| boolean isMidSize = hasWidth |
| && (origSize.width >= mMinMidSizeWidthDp) |
| && (origSize.width < mMinFullSizeWidthDp); |
| |
| final String newImageTag; |
| if (isFullSize) { |
| newImageTag = makeFullSizeImageTag(imageUrl, origSize.width, origSize.height); |
| } else if (isMidSize) { |
| newImageTag = makeImageTag(imageUrl, origSize.width, origSize.height, "size-medium"); |
| } else if (hasWidth) { |
| newImageTag = makeImageTag(imageUrl, origSize.width, origSize.height, "size-none"); |
| } else { |
| newImageTag = "<img class='size-none' src='" + imageUrl + "' />"; |
| } |
| |
| int start = mRenderBuilder.indexOf(imageTag); |
| if (start == -1) { |
| AppLog.w(AppLog.T.READER, "reader renderer > image not found in builder"); |
| return; |
| } |
| |
| mRenderBuilder.replace(start, start + imageTag.length(), newImageTag); |
| } |
| |
| private String makeImageTag(final String imageUrl, int width, int height, final String imageClass) { |
| String newImageUrl = ReaderUtils.getResizedImageUrl(imageUrl, width, height, mPost.isPrivate); |
| if (height > 0) { |
| return "<img class='" + imageClass + "'" + |
| " src='" + newImageUrl + "'" + |
| " width='" + pxToDp(width) + "'" + |
| " height='" + pxToDp(height) + "' />"; |
| } else { |
| return "<img class='" + imageClass + "'" + |
| "src='" + newImageUrl + "'" + |
| " width='" + pxToDp(width) + "' />"; |
| } |
| } |
| |
| private String makeFullSizeImageTag(final String imageUrl, int width, int height) { |
| int newWidth; |
| int newHeight; |
| if (width > 0 && height > 0) { |
| if (height > width) { |
| //noinspection SuspiciousNameCombination |
| newHeight = mResourceVars.fullSizeImageWidthPx; |
| float ratio = ((float) width / (float) height); |
| newWidth = (int) (newHeight * ratio); |
| } else { |
| float ratio = ((float) height / (float) width); |
| newWidth = mResourceVars.fullSizeImageWidthPx; |
| newHeight = (int) (newWidth * ratio); |
| } |
| } else { |
| newWidth = mResourceVars.fullSizeImageWidthPx; |
| newHeight = 0; |
| } |
| |
| return makeImageTag(imageUrl, newWidth, newHeight, "size-full"); |
| } |
| |
| /* |
| * returns true if the post has a featured image and there are no images in the |
| * post's content - when this is the case, the featured image is inserted at |
| * the top of the content |
| */ |
| private boolean shouldAddFeaturedImage() { |
| return mPost.hasFeaturedImage() |
| && !mPost.getText().contains("<img") |
| && !PhotonUtils.isMshotsUrl(mPost.getFeaturedImage()); |
| } |
| |
| /* |
| * returns the basic content of the post tweaked for use here |
| */ |
| private String getPostContent() { |
| // some content (such as Vimeo embeds) don't have "http:" before links |
| String content = mPost.getText().replace("src=\"//", "src=\"http://"); |
| |
| // add the featured image (if any) |
| if (shouldAddFeaturedImage()) { |
| AppLog.d(AppLog.T.READER, "reader renderer > added featured image"); |
| content = getFeaturedImageHtml() + content; |
| } |
| |
| // if this is a Discover post, add a link which shows the blog preview |
| if (mPost.isDiscoverPost()) { |
| ReaderPostDiscoverData discoverData = mPost.getDiscoverData(); |
| if (discoverData != null && discoverData.getBlogId() != 0 && discoverData.hasBlogName()) { |
| String label = String.format( |
| WordPress.getContext().getString(R.string.reader_discover_visit_blog), discoverData.getBlogName()); |
| String url = ReaderUtils.makeBlogPreviewUrl(discoverData.getBlogId()); |
| |
| String htmlDiscover = "<div id='discover'>" |
| + "<a href='" + url + "'>" + label + "</a>" |
| + "</div>"; |
| content += htmlDiscover; |
| } |
| } |
| |
| return content; |
| } |
| |
| /* |
| * returns the HTML that was last rendered, will be null prior to rendering |
| */ |
| String getRenderedHtml() { |
| return mRenderedHtml; |
| } |
| |
| /* |
| * returns the HTML to use when inserting a featured image into the rendered content |
| */ |
| private String getFeaturedImageHtml() { |
| String imageUrl = ReaderUtils.getResizedImageUrl( |
| mPost.getFeaturedImage(), |
| mResourceVars.fullSizeImageWidthPx, |
| mResourceVars.featuredImageHeightPx, |
| mPost.isPrivate); |
| |
| return "<img class='size-full' src='" + imageUrl + "'/>"; |
| } |
| |
| /* |
| * replace the passed iframe tag with one that's correctly sized for the device |
| */ |
| private void replaceIframeTag(final String tag, final String src) { |
| int width = ReaderHtmlUtils.getWidthAttrValue(tag); |
| int height = ReaderHtmlUtils.getHeightAttrValue(tag); |
| |
| int newHeight; |
| int newWidth; |
| if (width > 0 && height > 0) { |
| float ratio = ((float) height / (float) width); |
| newWidth = mResourceVars.videoWidthPx; |
| newHeight = (int) (newWidth * ratio); |
| } else { |
| newWidth = mResourceVars.videoWidthPx; |
| newHeight = mResourceVars.videoHeightPx; |
| } |
| |
| String newTag = "<iframe src='" + src + "'" + |
| " frameborder='0' allowfullscreen='true' allowtransparency='true'" + |
| " width='" + pxToDp(newWidth) + "'" + |
| " height='" + pxToDp(newHeight) + "' />"; |
| |
| int start = mRenderBuilder.indexOf(tag); |
| if (start == -1) { |
| AppLog.w(AppLog.T.READER, "reader renderer > iframe not found in builder"); |
| return; |
| } |
| |
| mRenderBuilder.replace(start, start + tag.length(), newTag); |
| } |
| |
| /* |
| * returns the full content, including CSS, that will be shown in the WebView for this post |
| */ |
| private String formatPostContentForWebView(final String content, boolean hasTiledGallery, boolean isWideDisplay) { |
| final boolean renderAsTiledGallery = hasTiledGallery && isWideDisplay; |
| |
| // unique CSS class assigned to the gallery elements for easy selection |
| final String galleryOnlyClass = "gallery-only-class" + new Random().nextInt(1000); |
| |
| @SuppressWarnings("StringBufferReplaceableByString") |
| StringBuilder sbHtml = new StringBuilder("<!DOCTYPE html><html><head><meta charset='UTF-8' />"); |
| |
| // title isn't necessary, but it's invalid html5 without one |
| sbHtml.append("<title>Reader Post</title>") |
| |
| // https://developers.google.com/chrome/mobile/docs/webview/pixelperfect |
| .append("<meta name='viewport' content='width=device-width, initial-scale=1'>") |
| |
| // use Merriweather font assets |
| .append("<link href='file:///android_asset/merriweather.css' rel='stylesheet' type='text/css'>") |
| |
| .append("<style type='text/css'>") |
| .append(" body { font-family: Merriweather, serif; font-weight: 400; margin: 0px; padding: 0px;}") |
| .append(" body, p, div { max-width: 100% !important; word-wrap: break-word; }") |
| |
| // set line-height, font-size but not for gallery divs when rendering as tiled gallery as those will be |
| // handled with the .tiled-gallery rules bellow. |
| .append(" p, div" + (renderAsTiledGallery ? ":not(." + galleryOnlyClass + ")" : "") + |
| ", li { line-height: 1.6em; font-size: 100%; }") |
| |
| .append(" h1, h2 { line-height: 1.2em; }") |
| |
| // counteract pre-defined height/width styles, except for the tiled-gallery divs when rendering as tiled gallery |
| // as those will be handled with the .tiled-gallery rules bellow. |
| .append(" p, div" + (renderAsTiledGallery ? ":not(." + galleryOnlyClass + ")" : "") + |
| ", dl, table { width: auto !important; height: auto !important; }") |
| |
| // make sure long strings don't force the user to scroll horizontally |
| .append(" body, p, div, a { word-wrap: break-word; }") |
| |
| // use a consistent top/bottom margin for paragraphs, with no top margin for the first one |
| .append(" p { margin-top: ").append(mResourceVars.marginMediumPx).append("px;") |
| .append(" margin-bottom: ").append(mResourceVars.marginMediumPx).append("px; }") |
| .append(" p:first-child { margin-top: 0px; }") |
| |
| // add background color and padding to pre blocks, and add overflow scrolling |
| // so user can scroll the block if it's wider than the display |
| .append(" pre { overflow-x: scroll;") |
| .append(" background-color: ").append(mResourceVars.greyExtraLightStr).append("; ") |
| .append(" padding: ").append(mResourceVars.marginMediumPx).append("px; }") |
| |
| // add a left border to blockquotes |
| .append(" blockquote { margin-left: ").append(mResourceVars.marginMediumPx).append("px; ") |
| .append(" padding-left: ").append(mResourceVars.marginMediumPx).append("px; ") |
| .append(" border-left: 3px solid ").append(mResourceVars.greyLightStr).append("; }") |
| |
| // show links in the same color they are elsewhere in the app |
| .append(" a { text-decoration: none; color: ").append(mResourceVars.linkColorStr).append("; }") |
| |
| // make sure images aren't wider than the display, strictly enforced for images without size |
| .append(" img { max-width: 100%; width: auto; height: auto; }") |
| .append(" img.size-none { max-width: 100% !important; height: auto !important; }") |
| |
| // center large/medium images, provide a small bottom margin, and add a background color |
| // so the user sees something while they're loading |
| .append(" img.size-full, img.size-large, img.size-medium {") |
| .append(" display: block; margin-left: auto; margin-right: auto;") |
| .append(" background-color: ").append(mResourceVars.greyExtraLightStr).append(";") |
| .append(" margin-bottom: ").append(mResourceVars.marginMediumPx).append("px; }"); |
| |
| if (isWideDisplay) { |
| sbHtml |
| .append(".alignleft {") |
| .append(" max-width: 100%;") |
| .append(" float: left;") |
| .append(" margin-top: 12px;") |
| .append(" margin-bottom: 12px;") |
| .append(" margin-right: 32px;}") |
| .append(".alignright {") |
| .append(" max-width: 100%;") |
| .append(" float: right;") |
| .append(" margin-top: 12px;") |
| .append(" margin-bottom: 12px;") |
| .append(" margin-left: 32px;}"); |
| } |
| |
| if (renderAsTiledGallery) { |
| // tiled-gallery related styles |
| sbHtml |
| .append(".tiled-gallery {") |
| .append(" clear:both;") |
| .append(" overflow:hidden;}") |
| .append(".tiled-gallery img {") |
| .append(" margin:2px !important;}") |
| .append(".tiled-gallery .gallery-group {") |
| .append(" float:left;") |
| .append(" position:relative;}") |
| .append(".tiled-gallery .tiled-gallery-item {") |
| .append(" float:left;") |
| .append(" margin:0;") |
| .append(" position:relative;") |
| .append(" width:inherit;}") |
| .append(".tiled-gallery .gallery-row {") |
| .append(" position: relative;") |
| .append(" left: 50%;") |
| .append(" -webkit-transform: translateX(-50%);") |
| .append(" -moz-transform: translateX(-50%);") |
| .append(" transform: translateX(-50%);") |
| .append(" overflow:hidden;}") |
| .append(".tiled-gallery .tiled-gallery-item a {") |
| .append(" background:transparent;") |
| .append(" border:none;") |
| .append(" color:inherit;") |
| .append(" margin:0;") |
| .append(" padding:0;") |
| .append(" text-decoration:none;") |
| .append(" width:auto;}") |
| .append(".tiled-gallery .tiled-gallery-item img,") |
| .append(".tiled-gallery .tiled-gallery-item img:hover {") |
| .append(" background:none;") |
| .append(" border:none;") |
| .append(" box-shadow:none;") |
| .append(" max-width:100%;") |
| .append(" padding:0;") |
| .append(" vertical-align:middle;}") |
| .append(".tiled-gallery-caption {") |
| .append(" background:#eee;") |
| .append(" background:rgba( 255,255,255,0.8 );") |
| .append(" color:#333;") |
| .append(" font-size:13px;") |
| .append(" font-weight:400;") |
| .append(" overflow:hidden;") |
| .append(" padding:10px 0;") |
| .append(" position:absolute;") |
| .append(" bottom:0;") |
| .append(" text-indent:10px;") |
| .append(" text-overflow:ellipsis;") |
| .append(" width:100%;") |
| .append(" white-space:nowrap;}") |
| .append(".tiled-gallery .tiled-gallery-item-small .tiled-gallery-caption {") |
| .append(" font-size:11px;}") |
| .append(".widget-gallery .tiled-gallery-unresized {") |
| .append(" visibility:hidden;") |
| .append(" height:0px;") |
| .append(" overflow:hidden;}") |
| .append(".tiled-gallery .tiled-gallery-item img.grayscale {") |
| .append(" position:absolute;") |
| .append(" left:0;") |
| .append(" top:0;}") |
| .append(".tiled-gallery .tiled-gallery-item img.grayscale:hover {") |
| .append(" opacity:0;}") |
| .append(".tiled-gallery.type-circle .tiled-gallery-item img {") |
| .append(" border-radius:50% !important;}") |
| .append(".tiled-gallery.type-circle .tiled-gallery-caption {") |
| .append(" display:none;") |
| .append(" opacity:0;}"); |
| } |
| |
| // see http://codex.wordpress.org/CSS#WordPress_Generated_Classes |
| sbHtml |
| .append(" .wp-caption img { margin-top: 0px; margin-bottom: 0px; }") |
| .append(" .wp-caption .wp-caption-text {") |
| .append(" font-size: smaller; line-height: 1.2em; margin: 0px;") |
| .append(" text-align: center;") |
| .append(" padding: ").append(mResourceVars.marginMediumPx).append("px; ") |
| .append(" color: ").append(mResourceVars.greyMediumDarkStr).append("; }") |
| |
| // attribution for Discover posts |
| .append(" div#discover { ") |
| .append(" margin-top: ").append(mResourceVars.marginMediumPx).append("px;") |
| .append(" font-family: sans-serif;") |
| .append(" }") |
| |
| // horizontally center iframes |
| .append(" iframe { display: block; margin: 0 auto; }") |
| |
| // make sure html5 videos fit the browser width and use 16:9 ratio (YouTube standard) |
| .append(" video {") |
| .append(" width: ").append(pxToDp(mResourceVars.videoWidthPx)).append("px !important;") |
| .append(" height: ").append(pxToDp(mResourceVars.videoHeightPx)).append("px !important; }") |
| |
| .append("</style>"); |
| |
| // add a custom CSS class to (any) tiled gallery elements to make them easier selectable for various rules |
| final List<String> classAmendRegexes = Arrays.asList( |
| "(tiled-gallery)([\\s\"\'])", |
| "(gallery-row)([\\s\"'])", |
| "(gallery-group)([\\s\"'])", |
| "(tiled-gallery-item)([\\s\"'])"); |
| String contentCustomised = content; |
| for (String classToAmend : classAmendRegexes) { |
| contentCustomised = contentCustomised.replaceAll(classToAmend, "$1 " + galleryOnlyClass + "$2"); |
| } |
| |
| sbHtml.append("</head><body>") |
| .append(contentCustomised) |
| .append("</body></html>"); |
| |
| return sbHtml.toString(); |
| } |
| |
| private ImageSize getImageSize(final String imageTag, final String imageUrl) { |
| ImageSize size = getImageSizeFromAttachments(imageUrl); |
| if (size == null && imageTag.contains("data-orig-size=")) { |
| size = getImageOriginalSizeFromAttributes(imageTag); |
| } |
| if (size == null && imageUrl.contains("?")) { |
| size = getImageSizeFromQueryParams(imageUrl); |
| } |
| if (size == null && imageTag.contains("width=")) { |
| size = getImageSizeFromAttributes(imageTag); |
| } |
| return size; |
| } |
| |
| private ImageSize getImageSizeFromAttachments(final String imageUrl) { |
| if (mAttachmentSizes == null) { |
| mAttachmentSizes = new ImageSizeMap(mPost.getAttachmentsJson()); |
| } |
| return mAttachmentSizes.getImageSize(imageUrl); |
| } |
| |
| private ImageSize getImageSizeFromQueryParams(final String imageUrl) { |
| if (imageUrl.contains("w=")) { |
| Uri uri = Uri.parse(imageUrl.replace("&", "&")); |
| return new ImageSize( |
| StringUtils.stringToInt(uri.getQueryParameter("w")), |
| StringUtils.stringToInt(uri.getQueryParameter("h"))); |
| } else if (imageUrl.contains("resize=")) { |
| Uri uri = Uri.parse(imageUrl.replace("&", "&")); |
| String param = uri.getQueryParameter("resize"); |
| if (param != null) { |
| String[] sizes = param.split(","); |
| if (sizes.length == 2) { |
| return new ImageSize( |
| StringUtils.stringToInt(sizes[0]), |
| StringUtils.stringToInt(sizes[1])); |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| private ImageSize getImageOriginalSizeFromAttributes(final String imageTag) { |
| return new ImageSize( |
| ReaderHtmlUtils.getOriginalWidthAttrValue(imageTag), |
| ReaderHtmlUtils.getOriginalHeightAttrValue(imageTag)); |
| } |
| |
| private ImageSize getImageSizeFromAttributes(final String imageTag) { |
| return new ImageSize( |
| ReaderHtmlUtils.getWidthAttrValue(imageTag), |
| ReaderHtmlUtils.getHeightAttrValue(imageTag)); |
| } |
| |
| private int pxToDp(int px) { |
| if (px == 0) { |
| return 0; |
| } |
| return DisplayUtils.pxToDp(WordPress.getContext(), px); |
| } |
| |
| } |