1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.mail.utils;
17
18import android.app.DownloadManager;
19import android.content.Context;
20import android.content.res.AssetFileDescriptor;
21import android.content.res.AssetFileDescriptor.AutoCloseInputStream;
22import android.net.ConnectivityManager;
23import android.net.NetworkInfo;
24import android.os.Bundle;
25import android.os.ParcelFileDescriptor;
26import android.os.SystemClock;
27import android.text.TextUtils;
28
29import com.android.mail.R;
30import com.android.mail.providers.Attachment;
31import com.google.common.collect.ImmutableMap;
32
33import java.io.File;
34import java.io.FileInputStream;
35import java.io.FileNotFoundException;
36import java.io.FileOutputStream;
37import java.io.IOException;
38import java.io.InputStream;
39import java.text.DecimalFormat;
40import java.text.SimpleDateFormat;
41import java.util.Date;
42import java.util.Map;
43
44public class AttachmentUtils {
45    private static final String LOG_TAG = LogTag.getLogTag();
46
47    private static final int KILO = 1024;
48    private static final int MEGA = KILO * KILO;
49
50    /** Any IO reads should be limited to this timeout */
51    private static final long READ_TIMEOUT = 3600 * 1000;
52
53    private static final float MIN_CACHE_THRESHOLD = 0.25f;
54    private static final int MIN_CACHE_AVAILABLE_SPACE_BYTES = 100 * 1024 * 1024;
55
56    /**
57     * Singleton map of MIME->friendly description
58     * @see #getMimeTypeDisplayName(Context, String)
59     */
60    private static Map<String, String> sDisplayNameMap;
61
62    /**
63     * @return A string suitable for display in bytes, kilobytes or megabytes
64     *         depending on its size.
65     */
66    public static String convertToHumanReadableSize(Context context, long size) {
67        final String count;
68        if (size == 0) {
69            return "";
70        } else if (size < KILO) {
71            count = String.valueOf(size);
72            return context.getString(R.string.bytes, count);
73        } else if (size < MEGA) {
74            count = String.valueOf(size / KILO);
75            return context.getString(R.string.kilobytes, count);
76        } else {
77            DecimalFormat onePlace = new DecimalFormat("0.#");
78            count = onePlace.format((float) size / (float) MEGA);
79            return context.getString(R.string.megabytes, count);
80        }
81    }
82
83    /**
84     * Return a friendly localized file type for this attachment, or the empty string if
85     * unknown.
86     * @param context a Context to do resource lookup against
87     * @return friendly file type or empty string
88     */
89    public static String getDisplayType(final Context context, final Attachment attachment) {
90        if ((attachment.flags & Attachment.FLAG_DUMMY_ATTACHMENT) != 0) {
91            // This is a dummy attachment, display blank for type.
92            return "";
93        }
94
95        // try to get a friendly name for the exact mime type
96        // then try to show a friendly name for the mime family
97        // finally, give up and just show the file extension
98        final String contentType = attachment.getContentType();
99        String displayType = getMimeTypeDisplayName(context, contentType);
100        int index = !TextUtils.isEmpty(contentType) ? contentType.indexOf('/') : -1;
101        if (displayType == null && index > 0) {
102            displayType = getMimeTypeDisplayName(context, contentType.substring(0, index));
103        }
104        if (displayType == null) {
105            String extension = Utils.getFileExtension(attachment.getName());
106            // show '$EXTENSION File' for unknown file types
107            if (extension != null && extension.length() > 1 && extension.indexOf('.') == 0) {
108                displayType = context.getString(R.string.attachment_unknown,
109                        extension.substring(1).toUpperCase());
110            }
111        }
112        if (displayType == null) {
113            // no extension to display, but the map doesn't accept null entries
114            displayType = "";
115        }
116        return displayType;
117    }
118
119    /**
120     * Returns a user-friendly localized description of either a complete a MIME type or a
121     * MIME family.
122     * @param context used to look up localized strings
123     * @param type complete MIME type or just MIME family
124     * @return localized description text, or null if not recognized
125     */
126    public static synchronized String getMimeTypeDisplayName(final Context context,
127            String type) {
128        if (sDisplayNameMap == null) {
129            String docName = context.getString(R.string.attachment_application_msword);
130            String presoName = context.getString(R.string.attachment_application_vnd_ms_powerpoint);
131            String sheetName = context.getString(R.string.attachment_application_vnd_ms_excel);
132
133            sDisplayNameMap = new ImmutableMap.Builder<String, String>()
134                .put("image", context.getString(R.string.attachment_image))
135                .put("audio", context.getString(R.string.attachment_audio))
136                .put("video", context.getString(R.string.attachment_video))
137                .put("text", context.getString(R.string.attachment_text))
138                .put("application/pdf", context.getString(R.string.attachment_application_pdf))
139
140                // Documents
141                .put("application/msword", docName)
142                .put("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
143                        docName)
144
145                // Presentations
146                .put("application/vnd.ms-powerpoint",
147                        presoName)
148                .put("application/vnd.openxmlformats-officedocument.presentationml.presentation",
149                        presoName)
150
151                // Spreadsheets
152                .put("application/vnd.ms-excel", sheetName)
153                .put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
154                        sheetName)
155
156                .build();
157        }
158        return sDisplayNameMap.get(type);
159    }
160
161    /**
162     * Cache the file specified by the given attachment.  This will attempt to use any
163     * {@link ParcelFileDescriptor} in the Bundle parameter
164     * @param context
165     * @param attachment  Attachment to be cached
166     * @param attachmentFds optional {@link Bundle} containing {@link ParcelFileDescriptor} if the
167     *        caller has opened the files
168     * @return String file path for the cached attachment
169     */
170    // TODO(pwestbro): Once the attachment has a field for the cached path, this method should be
171    // changed to update the attachment, and return a boolean indicating that the attachment has
172    // been cached.
173    public static String cacheAttachmentUri(Context context, Attachment attachment,
174            Bundle attachmentFds) {
175        final File cacheDir = context.getCacheDir();
176
177        final long totalSpace = cacheDir.getTotalSpace();
178        if (attachment.size > 0) {
179            final long usableSpace = cacheDir.getUsableSpace() - attachment.size;
180            if (isLowSpace(totalSpace, usableSpace)) {
181                LogUtils.w(LOG_TAG, "Low memory (%d/%d). Can't cache attachment %s",
182                        usableSpace, totalSpace, attachment);
183                return null;
184            }
185        }
186        InputStream inputStream = null;
187        FileOutputStream outputStream = null;
188        File file = null;
189        try {
190            final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-kk:mm:ss");
191            file = File.createTempFile(dateFormat.format(new Date()), ".attachment", cacheDir);
192            final AssetFileDescriptor fileDescriptor = attachmentFds != null
193                    && attachment.contentUri != null ? (AssetFileDescriptor) attachmentFds
194                    .getParcelable(attachment.contentUri.toString())
195                    : null;
196            if (fileDescriptor != null) {
197                // Get the input stream from the file descriptor
198                inputStream = new AutoCloseInputStream(fileDescriptor);
199            } else {
200                if (attachment.contentUri == null) {
201                    // The contentUri of the attachment is null.  This can happen when sending a
202                    // message that has been previously saved, and the attachments had been
203                    // uploaded.
204                    LogUtils.d(LOG_TAG, "contentUri is null in attachment: %s", attachment);
205                    throw new FileNotFoundException("Missing contentUri in attachment");
206                }
207                // Attempt to open the file
208                if (attachment.virtualMimeType == null) {
209                    inputStream = context.getContentResolver().openInputStream(attachment.contentUri);
210                } else {
211                    AssetFileDescriptor fd = context.getContentResolver().openTypedAssetFileDescriptor(
212                            attachment.contentUri, attachment.virtualMimeType, null, null);
213                    if (fd != null) {
214                        inputStream = new AutoCloseInputStream(fd);
215                    }
216                }
217            }
218            outputStream = new FileOutputStream(file);
219            final long now = SystemClock.elapsedRealtime();
220            final byte[] bytes = new byte[1024];
221            while (true) {
222                int len = inputStream.read(bytes);
223                if (len <= 0) {
224                    break;
225                }
226                outputStream.write(bytes, 0, len);
227                if (SystemClock.elapsedRealtime() - now > READ_TIMEOUT) {
228                    throw new IOException("Timed out reading attachment data");
229                }
230            }
231            outputStream.flush();
232            String cachedFileUri = file.getAbsolutePath();
233            LogUtils.d(LOG_TAG, "Cached %s to %s", attachment.contentUri, cachedFileUri);
234
235            final long usableSpace = cacheDir.getUsableSpace();
236            if (isLowSpace(totalSpace, usableSpace)) {
237                file.delete();
238                LogUtils.w(LOG_TAG, "Low memory (%d/%d). Can't cache attachment %s",
239                        usableSpace, totalSpace, attachment);
240                cachedFileUri = null;
241            }
242
243            return cachedFileUri;
244        } catch (IOException | SecurityException e) {
245            // Catch any exception here to allow for unexpected failures during caching se we don't
246            // leave app in inconsistent state as we call this method outside of a transaction for
247            // performance reasons.
248            LogUtils.e(LOG_TAG, e, "Failed to cache attachment %s", attachment);
249            if (file != null) {
250                file.delete();
251            }
252            return null;
253        } finally {
254            try {
255                if (inputStream != null) {
256                    inputStream.close();
257                }
258                if (outputStream != null) {
259                    outputStream.close();
260                }
261            } catch (IOException e) {
262                LogUtils.w(LOG_TAG, e, "Failed to close stream");
263            }
264        }
265    }
266
267    private static boolean isLowSpace(long totalSpace, long usableSpace) {
268        // For caching attachments we want to enable caching if there is
269        // more than 100MB available, or if 25% of total space is free on devices
270        // where the cache partition is < 400MB.
271        return usableSpace <
272                Math.min(totalSpace * MIN_CACHE_THRESHOLD, MIN_CACHE_AVAILABLE_SPACE_BYTES);
273    }
274
275    /**
276     * Checks if the attachment can be downloaded with the current network
277     * connection.
278     *
279     * @param attachment the attachment to be checked
280     * @return true if the attachment can be downloaded.
281     */
282    public static boolean canDownloadAttachment(Context context, Attachment attachment) {
283        ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(
284                Context.CONNECTIVITY_SERVICE);
285        NetworkInfo info = connectivityManager.getActiveNetworkInfo();
286        if (info == null) {
287            return false;
288        } else if (info.isConnected()) {
289            if (info.getType() != ConnectivityManager.TYPE_MOBILE) {
290                // not mobile network
291                return true;
292            } else {
293                // mobile network
294                Long maxBytes = DownloadManager.getMaxBytesOverMobile(context);
295                return maxBytes == null || attachment == null || attachment.size <= maxBytes;
296            }
297        } else {
298            return false;
299        }
300    }
301}
302