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