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