1/**
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy
6 * 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, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations
14 * under the License.
15 */
16
17package com.android.server.usage;
18
19import android.app.usage.TimeSparseArray;
20import android.app.usage.UsageStatsManager;
21import android.util.AtomicFile;
22import android.util.Slog;
23
24import java.io.BufferedReader;
25import java.io.BufferedWriter;
26import java.io.File;
27import java.io.FileReader;
28import java.io.FileWriter;
29import java.io.FilenameFilter;
30import java.io.IOException;
31import java.util.ArrayList;
32import java.util.List;
33
34/**
35 * Provides an interface to query for UsageStat data from an XML database.
36 */
37class UsageStatsDatabase {
38    private static final int CURRENT_VERSION = 2;
39
40    private static final String TAG = "UsageStatsDatabase";
41    private static final boolean DEBUG = UsageStatsService.DEBUG;
42    private static final String BAK_SUFFIX = ".bak";
43    private static final String CHECKED_IN_SUFFIX = UsageStatsXml.CHECKED_IN_SUFFIX;
44
45    private final Object mLock = new Object();
46    private final File[] mIntervalDirs;
47    private final TimeSparseArray<AtomicFile>[] mSortedStatFiles;
48    private final UnixCalendar mCal;
49    private final File mVersionFile;
50
51    public UsageStatsDatabase(File dir) {
52        mIntervalDirs = new File[] {
53                new File(dir, "daily"),
54                new File(dir, "weekly"),
55                new File(dir, "monthly"),
56                new File(dir, "yearly"),
57        };
58        mVersionFile = new File(dir, "version");
59        mSortedStatFiles = new TimeSparseArray[mIntervalDirs.length];
60        mCal = new UnixCalendar(0);
61    }
62
63    /**
64     * Initialize any directories required and index what stats are available.
65     */
66    public void init(long currentTimeMillis) {
67        synchronized (mLock) {
68            for (File f : mIntervalDirs) {
69                f.mkdirs();
70                if (!f.exists()) {
71                    throw new IllegalStateException("Failed to create directory "
72                            + f.getAbsolutePath());
73                }
74            }
75
76            checkVersionLocked();
77            indexFilesLocked();
78
79            // Delete files that are in the future.
80            for (TimeSparseArray<AtomicFile> files : mSortedStatFiles) {
81                final int startIndex = files.closestIndexOnOrAfter(currentTimeMillis);
82                if (startIndex < 0) {
83                    continue;
84                }
85
86                final int fileCount = files.size();
87                for (int i = startIndex; i < fileCount; i++) {
88                    files.valueAt(i).delete();
89                }
90
91                // Remove in a separate loop because any accesses (valueAt)
92                // will cause a gc in the SparseArray and mess up the order.
93                for (int i = startIndex; i < fileCount; i++) {
94                    files.removeAt(i);
95                }
96            }
97        }
98    }
99
100    public interface CheckinAction {
101        boolean checkin(IntervalStats stats);
102    }
103
104    /**
105     * Calls {@link CheckinAction#checkin(IntervalStats)} on the given {@link CheckinAction}
106     * for all {@link IntervalStats} that haven't been checked-in.
107     * If any of the calls to {@link CheckinAction#checkin(IntervalStats)} returns false or throws
108     * an exception, the check-in will be aborted.
109     *
110     * @param checkinAction The callback to run when checking-in {@link IntervalStats}.
111     * @return true if the check-in succeeded.
112     */
113    public boolean checkinDailyFiles(CheckinAction checkinAction) {
114        synchronized (mLock) {
115            final TimeSparseArray<AtomicFile> files =
116                    mSortedStatFiles[UsageStatsManager.INTERVAL_DAILY];
117            final int fileCount = files.size();
118
119            // We may have holes in the checkin (if there was an error)
120            // so find the last checked-in file and go from there.
121            int lastCheckin = -1;
122            for (int i = 0; i < fileCount - 1; i++) {
123                if (files.valueAt(i).getBaseFile().getPath().endsWith(CHECKED_IN_SUFFIX)) {
124                    lastCheckin = i;
125                }
126            }
127
128            final int start = lastCheckin + 1;
129            if (start == fileCount - 1) {
130                return true;
131            }
132
133            try {
134                IntervalStats stats = new IntervalStats();
135                for (int i = start; i < fileCount - 1; i++) {
136                    UsageStatsXml.read(files.valueAt(i), stats);
137                    if (!checkinAction.checkin(stats)) {
138                        return false;
139                    }
140                }
141            } catch (IOException e) {
142                Slog.e(TAG, "Failed to check-in", e);
143                return false;
144            }
145
146            // We have successfully checked-in the stats, so rename the files so that they
147            // are marked as checked-in.
148            for (int i = start; i < fileCount - 1; i++) {
149                final AtomicFile file = files.valueAt(i);
150                final File checkedInFile = new File(
151                        file.getBaseFile().getPath() + CHECKED_IN_SUFFIX);
152                if (!file.getBaseFile().renameTo(checkedInFile)) {
153                    // We must return success, as we've already marked some files as checked-in.
154                    // It's better to repeat ourselves than to lose data.
155                    Slog.e(TAG, "Failed to mark file " + file.getBaseFile().getPath()
156                            + " as checked-in");
157                    return true;
158                }
159
160                // AtomicFile needs to set a new backup path with the same -c extension, so
161                // we replace the old AtomicFile with the updated one.
162                files.setValueAt(i, new AtomicFile(checkedInFile));
163            }
164        }
165        return true;
166    }
167
168    private void indexFilesLocked() {
169        final FilenameFilter backupFileFilter = new FilenameFilter() {
170            @Override
171            public boolean accept(File dir, String name) {
172                return !name.endsWith(BAK_SUFFIX);
173            }
174        };
175
176        // Index the available usage stat files on disk.
177        for (int i = 0; i < mSortedStatFiles.length; i++) {
178            if (mSortedStatFiles[i] == null) {
179                mSortedStatFiles[i] = new TimeSparseArray<>();
180            } else {
181                mSortedStatFiles[i].clear();
182            }
183            File[] files = mIntervalDirs[i].listFiles(backupFileFilter);
184            if (files != null) {
185                if (DEBUG) {
186                    Slog.d(TAG, "Found " + files.length + " stat files for interval " + i);
187                }
188
189                for (File f : files) {
190                    final AtomicFile af = new AtomicFile(f);
191                    mSortedStatFiles[i].put(UsageStatsXml.parseBeginTime(af), af);
192                }
193            }
194        }
195    }
196
197    private void checkVersionLocked() {
198        int version;
199        try (BufferedReader reader = new BufferedReader(new FileReader(mVersionFile))) {
200            version = Integer.parseInt(reader.readLine());
201        } catch (NumberFormatException | IOException e) {
202            version = 0;
203        }
204
205        if (version != CURRENT_VERSION) {
206            Slog.i(TAG, "Upgrading from version " + version + " to " + CURRENT_VERSION);
207            doUpgradeLocked(version);
208
209            try (BufferedWriter writer = new BufferedWriter(new FileWriter(mVersionFile))) {
210                writer.write(Integer.toString(CURRENT_VERSION));
211            } catch (IOException e) {
212                Slog.e(TAG, "Failed to write new version");
213                throw new RuntimeException(e);
214            }
215        }
216    }
217
218    private void doUpgradeLocked(int thisVersion) {
219        if (thisVersion < 2) {
220            // Delete all files if we are version 0. This is a pre-release version,
221            // so this is fine.
222            Slog.i(TAG, "Deleting all usage stats files");
223            for (int i = 0; i < mIntervalDirs.length; i++) {
224                File[] files = mIntervalDirs[i].listFiles();
225                if (files != null) {
226                    for (File f : files) {
227                        f.delete();
228                    }
229                }
230            }
231        }
232    }
233
234    public void onTimeChanged(long timeDiffMillis) {
235        synchronized (mLock) {
236            for (TimeSparseArray<AtomicFile> files : mSortedStatFiles) {
237                final int fileCount = files.size();
238                for (int i = 0; i < fileCount; i++) {
239                    final AtomicFile file = files.valueAt(i);
240                    final long newTime = files.keyAt(i) + timeDiffMillis;
241                    if (newTime < 0) {
242                        Slog.i(TAG, "Deleting file " + file.getBaseFile().getAbsolutePath()
243                                + " for it is in the future now.");
244                        file.delete();
245                    } else {
246                        try {
247                            file.openRead().close();
248                        } catch (IOException e) {
249                            // Ignore, this is just to make sure there are no backups.
250                        }
251
252                        String newName = Long.toString(newTime);
253                        if (file.getBaseFile().getName().endsWith(CHECKED_IN_SUFFIX)) {
254                            newName = newName + CHECKED_IN_SUFFIX;
255                        }
256
257                        final File newFile = new File(file.getBaseFile().getParentFile(), newName);
258                        Slog.i(TAG, "Moving file " + file.getBaseFile().getAbsolutePath() + " to "
259                                + newFile.getAbsolutePath());
260                        file.getBaseFile().renameTo(newFile);
261                    }
262                }
263                files.clear();
264            }
265
266            // Now re-index the new files.
267            indexFilesLocked();
268        }
269    }
270
271    /**
272     * Get the latest stats that exist for this interval type.
273     */
274    public IntervalStats getLatestUsageStats(int intervalType) {
275        synchronized (mLock) {
276            if (intervalType < 0 || intervalType >= mIntervalDirs.length) {
277                throw new IllegalArgumentException("Bad interval type " + intervalType);
278            }
279
280            final int fileCount = mSortedStatFiles[intervalType].size();
281            if (fileCount == 0) {
282                return null;
283            }
284
285            try {
286                final AtomicFile f = mSortedStatFiles[intervalType].valueAt(fileCount - 1);
287                IntervalStats stats = new IntervalStats();
288                UsageStatsXml.read(f, stats);
289                return stats;
290            } catch (IOException e) {
291                Slog.e(TAG, "Failed to read usage stats file", e);
292            }
293        }
294        return null;
295    }
296
297    /**
298     * Get the time at which the latest stats begin for this interval type.
299     */
300    public long getLatestUsageStatsBeginTime(int intervalType) {
301        synchronized (mLock) {
302            if (intervalType < 0 || intervalType >= mIntervalDirs.length) {
303                throw new IllegalArgumentException("Bad interval type " + intervalType);
304            }
305
306            final int statsFileCount = mSortedStatFiles[intervalType].size();
307            if (statsFileCount > 0) {
308                return mSortedStatFiles[intervalType].keyAt(statsFileCount - 1);
309            }
310            return -1;
311        }
312    }
313
314    /**
315     * Figures out what to extract from the given IntervalStats object.
316     */
317    interface StatCombiner<T> {
318
319        /**
320         * Implementations should extract interesting from <code>stats</code> and add it
321         * to the <code>accumulatedResult</code> list.
322         *
323         * If the <code>stats</code> object is mutable, <code>mutable</code> will be true,
324         * which means you should make a copy of the data before adding it to the
325         * <code>accumulatedResult</code> list.
326         *
327         * @param stats The {@link IntervalStats} object selected.
328         * @param mutable Whether or not the data inside the stats object is mutable.
329         * @param accumulatedResult The list to which to add extracted data.
330         */
331        void combine(IntervalStats stats, boolean mutable, List<T> accumulatedResult);
332    }
333
334    /**
335     * Find all {@link IntervalStats} for the given range and interval type.
336     */
337    public <T> List<T> queryUsageStats(int intervalType, long beginTime, long endTime,
338            StatCombiner<T> combiner) {
339        synchronized (mLock) {
340            if (intervalType < 0 || intervalType >= mIntervalDirs.length) {
341                throw new IllegalArgumentException("Bad interval type " + intervalType);
342            }
343
344            final TimeSparseArray<AtomicFile> intervalStats = mSortedStatFiles[intervalType];
345
346            if (endTime <= beginTime) {
347                if (DEBUG) {
348                    Slog.d(TAG, "endTime(" + endTime + ") <= beginTime(" + beginTime + ")");
349                }
350                return null;
351            }
352
353            int startIndex = intervalStats.closestIndexOnOrBefore(beginTime);
354            if (startIndex < 0) {
355                // All the stats available have timestamps after beginTime, which means they all
356                // match.
357                startIndex = 0;
358            }
359
360            int endIndex = intervalStats.closestIndexOnOrBefore(endTime);
361            if (endIndex < 0) {
362                // All the stats start after this range ends, so nothing matches.
363                if (DEBUG) {
364                    Slog.d(TAG, "No results for this range. All stats start after.");
365                }
366                return null;
367            }
368
369            if (intervalStats.keyAt(endIndex) == endTime) {
370                // The endTime is exclusive, so if we matched exactly take the one before.
371                endIndex--;
372                if (endIndex < 0) {
373                    // All the stats start after this range ends, so nothing matches.
374                    if (DEBUG) {
375                        Slog.d(TAG, "No results for this range. All stats start after.");
376                    }
377                    return null;
378                }
379            }
380
381            try {
382                IntervalStats stats = new IntervalStats();
383                ArrayList<T> results = new ArrayList<>();
384                for (int i = startIndex; i <= endIndex; i++) {
385                    final AtomicFile f = intervalStats.valueAt(i);
386
387                    if (DEBUG) {
388                        Slog.d(TAG, "Reading stat file " + f.getBaseFile().getAbsolutePath());
389                    }
390
391                    UsageStatsXml.read(f, stats);
392                    if (beginTime < stats.endTime) {
393                        combiner.combine(stats, false, results);
394                    }
395                }
396                return results;
397            } catch (IOException e) {
398                Slog.e(TAG, "Failed to read usage stats file", e);
399                return null;
400            }
401        }
402    }
403
404    /**
405     * Find the interval that best matches this range.
406     *
407     * TODO(adamlesinski): Use endTimeStamp in best fit calculation.
408     */
409    public int findBestFitBucket(long beginTimeStamp, long endTimeStamp) {
410        synchronized (mLock) {
411            int bestBucket = -1;
412            long smallestDiff = Long.MAX_VALUE;
413            for (int i = mSortedStatFiles.length - 1; i >= 0; i--) {
414                final int index = mSortedStatFiles[i].closestIndexOnOrBefore(beginTimeStamp);
415                int size = mSortedStatFiles[i].size();
416                if (index >= 0 && index < size) {
417                    // We have some results here, check if they are better than our current match.
418                    long diff = Math.abs(mSortedStatFiles[i].keyAt(index) - beginTimeStamp);
419                    if (diff < smallestDiff) {
420                        smallestDiff = diff;
421                        bestBucket = i;
422                    }
423                }
424            }
425            return bestBucket;
426        }
427    }
428
429    /**
430     * Remove any usage stat files that are too old.
431     */
432    public void prune(final long currentTimeMillis) {
433        synchronized (mLock) {
434            mCal.setTimeInMillis(currentTimeMillis);
435            mCal.addYears(-3);
436            pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_YEARLY],
437                    mCal.getTimeInMillis());
438
439            mCal.setTimeInMillis(currentTimeMillis);
440            mCal.addMonths(-6);
441            pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_MONTHLY],
442                    mCal.getTimeInMillis());
443
444            mCal.setTimeInMillis(currentTimeMillis);
445            mCal.addWeeks(-4);
446            pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_WEEKLY],
447                    mCal.getTimeInMillis());
448
449            mCal.setTimeInMillis(currentTimeMillis);
450            mCal.addDays(-7);
451            pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_DAILY],
452                    mCal.getTimeInMillis());
453        }
454    }
455
456    private static void pruneFilesOlderThan(File dir, long expiryTime) {
457        File[] files = dir.listFiles();
458        if (files != null) {
459            for (File f : files) {
460                String path = f.getPath();
461                if (path.endsWith(BAK_SUFFIX)) {
462                    f = new File(path.substring(0, path.length() - BAK_SUFFIX.length()));
463                }
464                long beginTime = UsageStatsXml.parseBeginTime(f);
465                if (beginTime < expiryTime) {
466                    new AtomicFile(f).delete();
467                }
468            }
469        }
470    }
471
472    /**
473     * Update the stats in the database. They may not be written to disk immediately.
474     */
475    public void putUsageStats(int intervalType, IntervalStats stats) throws IOException {
476        synchronized (mLock) {
477            if (intervalType < 0 || intervalType >= mIntervalDirs.length) {
478                throw new IllegalArgumentException("Bad interval type " + intervalType);
479            }
480
481            AtomicFile f = mSortedStatFiles[intervalType].get(stats.beginTime);
482            if (f == null) {
483                f = new AtomicFile(new File(mIntervalDirs[intervalType],
484                        Long.toString(stats.beginTime)));
485                mSortedStatFiles[intervalType].put(stats.beginTime, f);
486            }
487
488            UsageStatsXml.write(f, stats);
489            stats.lastTimeSaved = f.getLastModifiedTime();
490        }
491    }
492}
493