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