Home | History | Annotate | Download | only in ui
      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