1/*
2 * Copyright (C) 2014 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.downloads;
18
19import static com.android.providers.downloads.Constants.TAG;
20import static com.android.providers.downloads.StorageUtils.listFilesRecursive;
21
22import android.app.DownloadManager;
23import android.app.job.JobParameters;
24import android.app.job.JobService;
25import android.content.ContentResolver;
26import android.content.ContentUris;
27import android.database.Cursor;
28import android.os.Environment;
29import android.provider.Downloads;
30import android.system.ErrnoException;
31import android.text.TextUtils;
32import android.text.format.DateUtils;
33import android.util.Slog;
34
35import com.android.providers.downloads.StorageUtils.ConcreteFile;
36import com.google.android.collect.Lists;
37import com.google.android.collect.Sets;
38
39import libcore.io.IoUtils;
40
41import java.io.File;
42import java.util.ArrayList;
43import java.util.HashSet;
44
45/**
46 * Idle-time service for {@link DownloadManager}. Reconciles database
47 * metadata and files on disk, which can become inconsistent when files are
48 * deleted directly on disk.
49 */
50public class DownloadIdleService extends JobService {
51
52    private class IdleRunnable implements Runnable {
53        private JobParameters mParams;
54
55        public IdleRunnable(JobParameters params) {
56            mParams = params;
57        }
58
59        @Override
60        public void run() {
61            cleanStale();
62            cleanOrphans();
63            jobFinished(mParams, false);
64        }
65    }
66
67    @Override
68    public boolean onStartJob(JobParameters params) {
69        new Thread(new IdleRunnable(params)).start();
70        return true;
71    }
72
73    @Override
74    public boolean onStopJob(JobParameters params) {
75        // We're okay being killed at any point, so we don't worry about
76        // checkpointing before tearing down.
77        return false;
78    }
79
80    private interface StaleQuery {
81        final String[] PROJECTION = new String[] {
82                Downloads.Impl._ID,
83                Downloads.Impl.COLUMN_STATUS,
84                Downloads.Impl.COLUMN_LAST_MODIFICATION,
85                Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI };
86
87        final int _ID = 0;
88    }
89
90    /**
91     * Remove stale downloads that third-party apps probably forgot about. We
92     * only consider non-visible downloads that haven't been touched in over a
93     * week.
94     */
95    public void cleanStale() {
96        final ContentResolver resolver = getContentResolver();
97
98        final long modifiedBefore = System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS;
99        final Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
100                StaleQuery.PROJECTION, Downloads.Impl.COLUMN_STATUS + " >= '200' AND "
101                        + Downloads.Impl.COLUMN_LAST_MODIFICATION + " <= '" + modifiedBefore
102                        + "' AND " + Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI + " == '0'",
103                null, null);
104
105        int count = 0;
106        try {
107            while (cursor.moveToNext()) {
108                final long id = cursor.getLong(StaleQuery._ID);
109                resolver.delete(ContentUris.withAppendedId(
110                        Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id), null, null);
111                count++;
112            }
113        } finally {
114            IoUtils.closeQuietly(cursor);
115        }
116
117        Slog.d(TAG, "Removed " + count + " stale downloads");
118    }
119
120    private interface OrphanQuery {
121        final String[] PROJECTION = new String[] {
122                Downloads.Impl._ID,
123                Downloads.Impl._DATA };
124
125        final int _ID = 0;
126        final int _DATA = 1;
127    }
128
129    /**
130     * Clean up orphan downloads, both in database and on disk.
131     */
132    public void cleanOrphans() {
133        final ContentResolver resolver = getContentResolver();
134
135        // Collect known files from database
136        final HashSet<ConcreteFile> fromDb = Sets.newHashSet();
137        final Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
138                OrphanQuery.PROJECTION, null, null, null);
139        try {
140            while (cursor.moveToNext()) {
141                final String path = cursor.getString(OrphanQuery._DATA);
142                if (TextUtils.isEmpty(path)) continue;
143
144                final File file = new File(path);
145                try {
146                    fromDb.add(new ConcreteFile(file));
147                } catch (ErrnoException e) {
148                    // File probably no longer exists
149                    final String state = Environment.getExternalStorageState(file);
150                    if (Environment.MEDIA_UNKNOWN.equals(state)
151                            || Environment.MEDIA_MOUNTED.equals(state)) {
152                        // File appears to live on internal storage, or a
153                        // currently mounted device, so remove it from database.
154                        // This logic preserves files on external storage while
155                        // media is removed.
156                        final long id = cursor.getLong(OrphanQuery._ID);
157                        Slog.d(TAG, "Missing " + file + ", deleting " + id);
158                        resolver.delete(ContentUris.withAppendedId(
159                                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id), null, null);
160                    }
161                }
162            }
163        } finally {
164            IoUtils.closeQuietly(cursor);
165        }
166
167        // Collect known files from disk
168        final int uid = android.os.Process.myUid();
169        final ArrayList<ConcreteFile> fromDisk = Lists.newArrayList();
170        fromDisk.addAll(listFilesRecursive(getCacheDir(), null, uid));
171        fromDisk.addAll(listFilesRecursive(getFilesDir(), null, uid));
172        fromDisk.addAll(listFilesRecursive(Environment.getDownloadCacheDirectory(), null, uid));
173
174        Slog.d(TAG, "Found " + fromDb.size() + " files in database");
175        Slog.d(TAG, "Found " + fromDisk.size() + " files on disk");
176
177        // Delete files no longer referenced by database
178        for (ConcreteFile file : fromDisk) {
179            if (!fromDb.contains(file)) {
180                Slog.d(TAG, "Missing db entry, deleting " + file.file);
181                file.file.delete();
182            }
183        }
184    }
185}
186