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 */
16
17package com.android.providers.contacts.debug;
18
19import com.android.providers.contacts.util.Hex;
20import com.google.common.io.Closeables;
21
22import android.content.Context;
23import android.net.Uri;
24import android.util.Log;
25
26import java.io.File;
27import java.io.FileInputStream;
28import java.io.FileOutputStream;
29import java.io.IOException;
30import java.io.InputStream;
31import java.security.SecureRandom;
32import java.util.zip.Deflater;
33import java.util.zip.ZipEntry;
34import java.util.zip.ZipOutputStream;
35
36/**
37 * Compress all files under the app data dir into a single zip file.
38 *
39 * Make sure not to output dump filenames anywhere, including logcat.
40 */
41public class DataExporter {
42    private static String TAG = "DataExporter";
43
44    public static final String ZIP_MIME_TYPE = "application/zip";
45
46    public static final String DUMP_FILE_DIRECTORY_NAME = "dumpedfiles";
47
48    public static final String OUT_FILE_SUFFIX = "-contacts-db.zip";
49    public static final String VALID_FILE_NAME_REGEX = "[0-9A-Fa-f]+-contacts-db\\.zip";
50
51    /**
52     * Compress all files under the app data dir into a single zip file, and return the content://
53     * URI to the file, which can be read via {@link DumpFileProvider}.
54     */
55    public static Uri exportData(Context context) throws IOException {
56        final String fileName = generateRandomName() + OUT_FILE_SUFFIX;
57        final File outFile = getOutputFile(context, fileName);
58
59        // Remove all existing ones.
60        removeDumpFiles(context);
61
62        Log.i(TAG, "Dump started...");
63
64        ensureOutputDirectory(context);
65        final ZipOutputStream os = new ZipOutputStream(new FileOutputStream(outFile));
66        os.setLevel(Deflater.BEST_COMPRESSION);
67        try {
68            addDirectory(context, os, context.getFilesDir().getParentFile(), "contacts-files");
69        } finally {
70            Closeables.closeQuietly(os);
71        }
72        Log.i(TAG, "Dump finished.");
73        return DumpFileProvider.AUTHORITY_URI.buildUpon().appendPath(fileName).build();
74    }
75
76    /** @return long random string for a file name */
77    private static String generateRandomName() {
78        final SecureRandom rng = new SecureRandom();
79        final byte[] random = new byte[256 / 8];
80        rng.nextBytes(random);
81
82        return Hex.encodeHex(random, true);
83    }
84
85    public static void ensureValidFileName(String fileName) {
86        // Do not allow queries to use relative paths to leave the root directory. Otherwise they
87        // can gain access to other files such as the contacts database.
88        if (fileName.contains("..")) {
89            throw new IllegalArgumentException(".. path specifier not allowed. Bad file name: " +
90                    fileName);
91        }
92        // White list dump files.
93        if (!fileName.matches(VALID_FILE_NAME_REGEX)) {
94            throw new IllegalArgumentException("Only " + VALID_FILE_NAME_REGEX +
95                    " files are supported. Bad file name: " + fileName);
96        }
97    }
98
99    private static File getOutputDirectory(Context context) {
100        return new File(context.getCacheDir(), DUMP_FILE_DIRECTORY_NAME);
101    }
102
103    private static void ensureOutputDirectory(Context context) {
104        final File directory = getOutputDirectory(context);
105        if (!directory.exists()) {
106            directory.mkdir();
107        }
108    }
109
110    public static File getOutputFile(Context context, String fileName) {
111        return new File(getOutputDirectory(context), fileName);
112    }
113
114    public static boolean dumpFileExists(Context context) {
115        return getOutputDirectory(context).exists();
116    }
117
118    public static void removeDumpFiles(Context context) {
119        removeFileOrDirectory(getOutputDirectory(context));
120    }
121
122    private static void removeFileOrDirectory(File file) {
123        if (!file.exists()) return;
124
125        if (file.isFile()) {
126            Log.i(TAG, "Removing " + file);
127            file.delete();
128            return;
129        }
130
131        if (file.isDirectory()) {
132            for (File child : file.listFiles()) {
133                removeFileOrDirectory(child);
134            }
135            Log.i(TAG, "Removing " + file);
136            file.delete();
137        }
138    }
139
140    /**
141     * Add all files under {@code current} to {@code os} zip stream
142     */
143    private static void addDirectory(Context context, ZipOutputStream os, File current,
144            String storedPath) throws IOException {
145        for (File child : current.listFiles()) {
146            final String childStoredPath = storedPath + "/" + child.getName();
147
148            if (child.isDirectory()) {
149                // Don't need the cache directory, which also contains the dump files.
150                if (child.equals(context.getCacheDir())) {
151                    continue;
152                }
153                // This check is redundant as the output directory should be in the cache dir,
154                // but just in case...
155                if (child.getName().equals(DUMP_FILE_DIRECTORY_NAME)) {
156                    continue;
157                }
158                addDirectory(context, os, child, childStoredPath);
159            } else if (child.isFile()) {
160                addFile(os, child, childStoredPath);
161            } else {
162                // Shouldn't happen; skip.
163            }
164        }
165    }
166
167    /**
168     * Add a single file {@code current} to {@code os} zip stream using the file name
169     * {@code storedPath}.
170     */
171    private static void addFile(ZipOutputStream os, File current, String storedPath)
172            throws IOException {
173        Log.i(TAG, "Adding " + current.getAbsolutePath() + " ...");
174        final InputStream is = new FileInputStream(current);
175        os.putNextEntry(new ZipEntry(storedPath));
176
177        final byte[] buf = new byte[32 * 1024];
178        int totalLen = 0;
179        while (true) {
180            int len = is.read(buf);
181            if (len <= 0) {
182                break;
183            }
184            os.write(buf, 0, len);
185            totalLen += len;
186        }
187        os.closeEntry();
188        Log.i(TAG, "Added " + current.getAbsolutePath() + " as " + storedPath +
189                " (" + totalLen + " bytes)");
190    }
191}
192