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