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.content.res.Resources.NotFoundException; 22 23 import com.android.mail.R; 24 import com.android.mail.utils.LogTag; 25 import com.android.mail.utils.LogUtils; 26 import com.android.mail.utils.Utils; 27 import com.google.common.annotations.VisibleForTesting; 28 29 import java.io.IOException; 30 import java.io.InputStreamReader; 31 import java.util.Formatter; 32 import java.util.regex.Pattern; 33 34 /** 35 * Renders data into very simple string-substitution HTML templates for conversation view. 36 * 37 * Templates should be UTF-8 encoded HTML with '%s' placeholders to be substituted upon render. 38 * Plain-jane string substitution with '%s' is slightly faster than typed substitution. 39 * 40 */ 41 public class HtmlConversationTemplates { 42 43 /** 44 * Prefix applied to a message id for use as a div id 45 */ 46 public static final String MESSAGE_PREFIX = "m"; 47 public static final int MESSAGE_PREFIX_LENGTH = MESSAGE_PREFIX.length(); 48 49 // TODO: refine. too expensive to iterate over cursor and pre-calculate total. so either 50 // estimate it, or defer assembly until the end when size is known (deferring increases 51 // working set size vs. estimation but is exact). 52 private static final int BUFFER_SIZE_CHARS = 64 * 1024; 53 54 private static final String TAG = LogTag.getLogTag(); 55 56 /** 57 * Pattern for HTML img tags with a "src" attribute where the value is an absolutely-specified 58 * HTTP or HTTPS URL. In other words, these are images with valid URLs that we should munge to 59 * prevent WebView from firing bad onload handlers for them. Part of the workaround for 60 * b/5522414. 61 * 62 * Pattern documentation: 63 * There are 3 top-level parts of the pattern: 64 * 1. required preceding string 65 * 2. the literal string "src" 66 * 3. required trailing string 67 * 68 * The preceding string must be an img tag "<img " with intermediate spaces allowed. The 69 * trailing whitespace is required. 70 * Non-whitespace chars are allowed before "src", but if they are present, they must be followed 71 * by another whitespace char. The idea is to allow other attributes, and avoid matching on 72 * "src" in a later attribute value as much as possible. 73 * 74 * The following string must contain "=" and "http", with intermediate whitespace and single- 75 * and double-quote allowed in between. The idea is to avoid matching Gmail-hosted relative URLs 76 * for inline attachment images of the form "?view=KEYVALUES". 77 * 78 */ 79 private static final Pattern sAbsoluteImgUrlPattern = Pattern.compile( 80 "(<\\s*img\\s+(?:[^>]*\\s+)?)src(\\s*=[\\s'\"]*http)", Pattern.CASE_INSENSITIVE 81 | Pattern.MULTILINE); 82 /** 83 * The text replacement for {@link #sAbsoluteImgUrlPattern}. The "src" attribute is set to 84 * something inert and not left unset to minimize interactions with existing JS. 85 */ 86 private static final String IMG_URL_REPLACEMENT = "$1src='data:' blocked-src$2"; 87 88 private static boolean sLoadedTemplates; 89 private static String sSuperCollapsed; 90 private static String sBorder; 91 private static String sMessage; 92 private static String sConversationUpper; 93 private static String sConversationLower; 94 95 private Context mContext; 96 private Formatter mFormatter; 97 private StringBuilder mBuilder; 98 private boolean mInProgress = false; 99 100 public HtmlConversationTemplates(Context context) { 101 mContext = context; 102 103 // The templates are small (~2KB total in ICS MR2), so it's okay to load them once and keep 104 // them in memory. 105 if (!sLoadedTemplates) { 106 sLoadedTemplates = true; 107 sSuperCollapsed = readTemplate(R.raw.template_super_collapsed); 108 sBorder = readTemplate(R.raw.template_border); 109 sMessage = readTemplate(R.raw.template_message); 110 sConversationUpper = readTemplate(R.raw.template_conversation_upper); 111 sConversationLower = readTemplate(R.raw.template_conversation_lower); 112 } 113 } 114 115 public void appendSuperCollapsedHtml(int firstCollapsed, int blockHeight) { 116 if (!mInProgress) { 117 throw new IllegalStateException("must call startConversation first"); 118 } 119 120 append(sSuperCollapsed, firstCollapsed, blockHeight); 121 } 122 123 /** 124 * Adds a spacer for the border that vertically separates cards. 125 * @param blockHeight height of the border 126 */ 127 public void appendBorder(int blockHeight) { 128 append(sBorder, blockHeight); 129 } 130 131 @VisibleForTesting 132 static String replaceAbsoluteImgUrls(final String html) { 133 return sAbsoluteImgUrlPattern.matcher(html).replaceAll(IMG_URL_REPLACEMENT); 134 } 135 136 public void appendMessageHtml(HtmlMessage message, boolean isExpanded, 137 boolean safeForImages, int headerHeight, int footerHeight) { 138 139 final String bodyDisplay = isExpanded ? "block" : "none"; 140 final String expandedClass = isExpanded ? "expanded" : ""; 141 final String showImagesClass = safeForImages ? "mail-show-images" : ""; 142 143 String body = message.getBodyAsHtml(); 144 145 /* Work around a WebView bug (5522414) in setBlockNetworkImage that causes img onload event 146 * handlers to fire before an image is loaded. 147 * WebView will report bad dimensions when revealing inline images with absolute URLs, but 148 * we can prevent WebView from ever seeing those images by changing all img "src" attributes 149 * into "gm-src" before loading the HTML. Parsing the potentially dirty HTML input is 150 * prohibitively expensive with TagSoup, so use a little regular expression instead. 151 * 152 * To limit the scope of this workaround, only use it on messages that the server claims to 153 * have external resources, and even then, only use it on img tags where the src is absolute 154 * (i.e. url does not begin with "?"). The existing JavaScript implementation of this 155 * attribute swap will continue to handle inline image attachments (they have relative 156 * URLs) and any false negatives that the regex misses. This maintains overall security 157 * level by not relying solely on the regex. 158 */ 159 if (!safeForImages && message.embedsExternalResources()) { 160 body = replaceAbsoluteImgUrls(body); 161 } 162 163 append(sMessage, 164 getMessageDomId(message), 165 expandedClass, 166 headerHeight, 167 showImagesClass, 168 bodyDisplay, 169 body, 170 bodyDisplay, 171 footerHeight 172 ); 173 } 174 175 public String getMessageDomId(HtmlMessage msg) { 176 return MESSAGE_PREFIX + msg.getId(); 177 } 178 179 public void startConversation(int sideMargin, int conversationHeaderHeight) { 180 if (mInProgress) { 181 throw new IllegalStateException("must call startConversation first"); 182 } 183 184 reset(); 185 final String border = Utils.isRunningKitkatOrLater() ? 186 "img[blocked-src] { border: 1px solid #CCCCCC; }" : ""; 187 append(sConversationUpper, border, sideMargin, conversationHeaderHeight); 188 mInProgress = true; 189 } 190 191 public String endConversation(String docBaseUri, String conversationBaseUri, 192 int viewportWidth, boolean enableContentReadySignal, boolean normalizeMessageWidths, 193 boolean enableMungeTables, boolean enableMungeImages) { 194 if (!mInProgress) { 195 throw new IllegalStateException("must call startConversation first"); 196 } 197 198 final String contentReadyClass = enableContentReadySignal ? "initial-load" : ""; 199 200 append(sConversationLower, contentReadyClass, mContext.getString(R.string.hide_elided), 201 mContext.getString(R.string.show_elided), docBaseUri, conversationBaseUri, 202 viewportWidth, enableContentReadySignal, normalizeMessageWidths, 203 enableMungeTables, enableMungeImages); 204 205 mInProgress = false; 206 207 LogUtils.d(TAG, "rendered conversation of %d bytes, buffer capacity=%d", 208 mBuilder.length() << 1, mBuilder.capacity() << 1); 209 210 return emit(); 211 } 212 213 public String emit() { 214 String out = mFormatter.toString(); 215 // release the builder memory ASAP 216 mFormatter = null; 217 mBuilder = null; 218 return out; 219 } 220 221 public void reset() { 222 mBuilder = new StringBuilder(BUFFER_SIZE_CHARS); 223 mFormatter = new Formatter(mBuilder, null /* no localization */); 224 } 225 226 private String readTemplate(int id) throws NotFoundException { 227 StringBuilder out = new StringBuilder(); 228 InputStreamReader in = null; 229 try { 230 try { 231 in = new InputStreamReader( 232 mContext.getResources().openRawResource(id), "UTF-8"); 233 char[] buf = new char[4096]; 234 int chars; 235 236 while ((chars=in.read(buf)) > 0) { 237 out.append(buf, 0, chars); 238 } 239 240 return out.toString(); 241 242 } finally { 243 if (in != null) { 244 in.close(); 245 } 246 } 247 } catch (IOException e) { 248 throw new NotFoundException("Unable to open template id=" + Integer.toHexString(id) 249 + " exception=" + e.getMessage()); 250 } 251 } 252 253 private void append(String template, Object... args) { 254 mFormatter.format(template, args); 255 } 256 257 } 258