1 /** 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.mail.ui; 19 20 import android.content.Context; 21 import android.support.v4.text.TextUtilsCompat; 22 import android.support.v4.view.ViewCompat; 23 24 import com.android.mail.R; 25 import com.android.mail.utils.LogTag; 26 import com.android.mail.utils.LogUtils; 27 import com.android.mail.utils.Utils; 28 import com.google.common.annotations.VisibleForTesting; 29 30 import java.util.Locale; 31 import java.util.regex.Pattern; 32 33 /** 34 * Renders data into very simple string-substitution HTML templates for conversation view. 35 */ 36 public class HtmlConversationTemplates extends AbstractHtmlTemplates { 37 38 /** 39 * Prefix applied to a message id for use as a div id 40 */ 41 public static final String MESSAGE_PREFIX = "m"; 42 public static final int MESSAGE_PREFIX_LENGTH = MESSAGE_PREFIX.length(); 43 44 private static final String TAG = LogTag.getLogTag(); 45 46 /** 47 * Pattern for HTML img tags with a "src" attribute where the value is an absolutely-specified 48 * HTTP or HTTPS URL. In other words, these are images with valid URLs that we should munge to 49 * prevent WebView from firing bad onload handlers for them. Part of the workaround for 50 * b/5522414. 51 * 52 * Pattern documentation: 53 * There are 3 top-level parts of the pattern: 54 * 1. required preceding string 55 * 2. the literal string "src" 56 * 3. required trailing string 57 * 58 * The preceding string must be an img tag "<img " with intermediate spaces allowed. The 59 * trailing whitespace is required. 60 * Non-whitespace chars are allowed before "src", but if they are present, they must be followed 61 * by another whitespace char. The idea is to allow other attributes, and avoid matching on 62 * "src" in a later attribute value as much as possible. 63 * 64 * The following string must contain "=" and "http", with intermediate whitespace and single- 65 * and double-quote allowed in between. The idea is to avoid matching Gmail-hosted relative URLs 66 * for inline attachment images of the form "?view=KEYVALUES". 67 * 68 */ 69 private static final Pattern sAbsoluteImgUrlPattern = Pattern.compile( 70 "(<\\s*img\\s+(?:[^>]*\\s+)?)src(\\s*=[\\s'\"]*http)", Pattern.CASE_INSENSITIVE 71 | Pattern.MULTILINE); 72 /** 73 * The text replacement for {@link #sAbsoluteImgUrlPattern}. The "src" attribute is set to 74 * something inert and not left unset to minimize interactions with existing JS. 75 */ 76 private static final String IMG_URL_REPLACEMENT = "$1src='data:' blocked-src$2"; 77 78 private static final String LEFT_TO_RIGHT_TRIANGLE = "\u25B6 "; 79 private static final String RIGHT_TO_LEFT_TRIANGLE = "\u25C0 "; 80 81 private static boolean sLoadedTemplates; 82 private static String sSuperCollapsed; 83 private static String sMessage; 84 private static String sConversationUpper; 85 private static String sConversationLower; 86 HtmlConversationTemplates(Context context)87 public HtmlConversationTemplates(Context context) { 88 super(context); 89 90 // The templates are small (~2KB total in ICS MR2), so it's okay to load them once and keep 91 // them in memory. 92 if (!sLoadedTemplates) { 93 sLoadedTemplates = true; 94 sSuperCollapsed = readTemplate(R.raw.template_super_collapsed); 95 sMessage = readTemplate(R.raw.template_message); 96 sConversationUpper = readTemplate(R.raw.template_conversation_upper); 97 sConversationLower = readTemplate(R.raw.template_conversation_lower); 98 } 99 } 100 appendSuperCollapsedHtml(int firstCollapsed, int blockHeight)101 public void appendSuperCollapsedHtml(int firstCollapsed, int blockHeight) { 102 if (!mInProgress) { 103 throw new IllegalStateException("must call startConversation first"); 104 } 105 106 append(sSuperCollapsed, firstCollapsed, blockHeight); 107 } 108 109 @VisibleForTesting replaceAbsoluteImgUrls(final String html)110 static String replaceAbsoluteImgUrls(final String html) { 111 return sAbsoluteImgUrlPattern.matcher(html).replaceAll(IMG_URL_REPLACEMENT); 112 } 113 114 /** 115 * Wrap a given message body string to prevent its contents from flowing out of the current DOM 116 * block context. 117 * 118 */ wrapMessageBody(String msgBody)119 public static String wrapMessageBody(String msgBody) { 120 // FIXME: this breaks RTL for an as-yet undetermined reason. b/13678928 121 // no-op for now. 122 return msgBody; 123 124 // final StringBuilder sb = new StringBuilder("<div style=\"display: table-cell;\">"); 125 // sb.append(msgBody); 126 // sb.append("</div>"); 127 // return sb.toString(); 128 } 129 appendMessageHtml(HtmlMessage message, boolean isExpanded, boolean safeForImages, int headerHeight, int footerHeight)130 public void appendMessageHtml(HtmlMessage message, boolean isExpanded, 131 boolean safeForImages, int headerHeight, int footerHeight) { 132 133 final String bodyDisplay = isExpanded ? "block" : "none"; 134 final String expandedClass = isExpanded ? "expanded" : ""; 135 final String showImagesClass = safeForImages ? "mail-show-images" : ""; 136 137 String body = message.getBodyAsHtml(); 138 139 /* Work around a WebView bug (5522414) in setBlockNetworkImage that causes img onload event 140 * handlers to fire before an image is loaded. 141 * WebView will report bad dimensions when revealing inline images with absolute URLs, but 142 * we can prevent WebView from ever seeing those images by changing all img "src" attributes 143 * into "gm-src" before loading the HTML. Parsing the potentially dirty HTML input is 144 * prohibitively expensive with TagSoup, so use a little regular expression instead. 145 * 146 * To limit the scope of this workaround, only use it on messages that the server claims to 147 * have external resources, and even then, only use it on img tags where the src is absolute 148 * (i.e. url does not begin with "?"). The existing JavaScript implementation of this 149 * attribute swap will continue to handle inline image attachments (they have relative 150 * URLs) and any false negatives that the regex misses. This maintains overall security 151 * level by not relying solely on the regex. 152 */ 153 if (!safeForImages && message.embedsExternalResources()) { 154 body = replaceAbsoluteImgUrls(body); 155 } 156 157 append(sMessage, 158 getMessageDomId(message), 159 expandedClass, 160 headerHeight, 161 showImagesClass, 162 bodyDisplay, 163 wrapMessageBody(body), 164 bodyDisplay, 165 footerHeight 166 ); 167 } 168 getMessageDomId(HtmlMessage msg)169 public String getMessageDomId(HtmlMessage msg) { 170 return MESSAGE_PREFIX + msg.getId(); 171 } 172 getMessageIdForDomId(String domMessageId)173 public String getMessageIdForDomId(String domMessageId) { 174 return domMessageId.substring(MESSAGE_PREFIX_LENGTH); 175 } 176 startConversation(int viewportWidth, int sideMargin, int conversationHeaderHeight)177 public void startConversation(int viewportWidth, int sideMargin, int conversationHeaderHeight) { 178 if (mInProgress) { 179 throw new IllegalStateException( 180 "Should not call start conversation until end conversation has been called"); 181 } 182 183 reset(); 184 final String border = Utils.isRunningKitkatOrLater() ? 185 "img[blocked-src] { border: 1px solid #CCCCCC; }" : ""; 186 append(sConversationUpper, viewportWidth, border, sideMargin, conversationHeaderHeight); 187 mInProgress = true; 188 } 189 endConversation(int convFooterPx, String docBaseUri, String conversationBaseUri, int viewportWidth, int webviewWidth, boolean enableContentReadySignal, boolean normalizeMessageWidths, boolean enableMungeTables, boolean enableMungeImages)190 public String endConversation(int convFooterPx, String docBaseUri, String conversationBaseUri, 191 int viewportWidth, int webviewWidth, boolean enableContentReadySignal, 192 boolean normalizeMessageWidths, boolean enableMungeTables, boolean enableMungeImages) { 193 if (!mInProgress) { 194 throw new IllegalStateException("must call startConversation first"); 195 } 196 197 final String contentReadyClass = enableContentReadySignal ? "initial-load" : ""; 198 199 final boolean isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) 200 == ViewCompat.LAYOUT_DIRECTION_RTL; 201 final String showElided = (isRtl ? RIGHT_TO_LEFT_TRIANGLE : LEFT_TO_RIGHT_TRIANGLE) + 202 mContext.getString(R.string.show_elided); 203 append(sConversationLower, convFooterPx, contentReadyClass, 204 mContext.getString(R.string.hide_elided), 205 showElided, docBaseUri, conversationBaseUri, viewportWidth, webviewWidth, 206 enableContentReadySignal, normalizeMessageWidths, 207 enableMungeTables, enableMungeImages, Utils.isRunningKitkatOrLater(), 208 mContext.getString(R.string.forms_are_disabled)); 209 210 mInProgress = false; 211 212 LogUtils.d(TAG, "rendered conversation of %d bytes, buffer capacity=%d", 213 mBuilder.length() << 1, mBuilder.capacity() << 1); 214 215 return emit(); 216 } 217 } 218