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 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 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 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 */ 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 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 169 public String getMessageDomId(HtmlMessage msg) { 170 return MESSAGE_PREFIX + msg.getId(); 171 } 172 173 public String getMessageIdForDomId(String domMessageId) { 174 return domMessageId.substring(MESSAGE_PREFIX_LENGTH); 175 } 176 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 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