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