bug:3099994 NPE in DownloadManager when deleting non-media file
[android/platform/packages/providers/DownloadProvider.git] / src / com / android / providers / downloads / Helpers.java
1 /*
2  * Copyright (C) 2008 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
17 package com.android.providers.downloads;
18
19 import android.content.ContentResolver;
20 import android.content.ContentUris;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.pm.PackageManager;
24 import android.content.pm.ResolveInfo;
25 import android.database.Cursor;
26 import android.drm.mobile1.DrmRawContent;
27 import android.net.Uri;
28 import android.os.Environment;
29 import android.os.StatFs;
30 import android.os.SystemClock;
31 import android.provider.Downloads;
32 import android.util.Config;
33 import android.util.Log;
34 import android.webkit.MimeTypeMap;
35
36 import java.io.File;
37 import java.util.Random;
38 import java.util.Set;
39 import java.util.regex.Matcher;
40 import java.util.regex.Pattern;
41
42 /**
43  * Some helper functions for the download manager
44  */
45 public class Helpers {
46
47     public static Random sRandom = new Random(SystemClock.uptimeMillis());
48
49     /** Regex used to parse content-disposition headers */
50     private static final Pattern CONTENT_DISPOSITION_PATTERN =
51             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
52
53     private Helpers() {
54     }
55
56     /*
57      * Parse the Content-Disposition HTTP Header. The format of the header
58      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
59      * This header provides a filename for content that is going to be
60      * downloaded to the file system. We only support the attachment type.
61      */
62     private static String parseContentDisposition(String contentDisposition) {
63         try {
64             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
65             if (m.find()) {
66                 return m.group(1);
67             }
68         } catch (IllegalStateException ex) {
69              // This function is defined as returning null when it can't parse the header
70         }
71         return null;
72     }
73
74     /**
75      * Exception thrown from methods called by generateSaveFile() for any fatal error.
76      */
77     public static class GenerateSaveFileError extends Exception {
78         int mStatus;
79         String mMessage;
80
81         public GenerateSaveFileError(int status, String message) {
82             mStatus = status;
83             mMessage = message;
84         }
85     }
86
87     /**
88      * Creates a filename (where the file should be saved) from info about a download.
89      */
90     public static String generateSaveFile(
91             Context context,
92             String url,
93             String hint,
94             String contentDisposition,
95             String contentLocation,
96             String mimeType,
97             int destination,
98             long contentLength,
99             boolean isPublicApi) throws GenerateSaveFileError {
100         checkCanHandleDownload(context, mimeType, destination, isPublicApi);
101         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
102             return getPathForFileUri(hint, contentLength);
103         } else {
104             return chooseFullPath(context, url, hint, contentDisposition, contentLocation, mimeType,
105                     destination, contentLength);
106         }
107     }
108
109     private static String getPathForFileUri(String hint, long contentLength)
110             throws GenerateSaveFileError {
111         if (!isExternalMediaMounted()) {
112             throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR,
113                     "external media not mounted");
114         }
115         String path = Uri.parse(hint).getPath();
116         if (new File(path).exists()) {
117             Log.d(Constants.TAG, "File already exists: " + path);
118             throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ALREADY_EXISTS_ERROR,
119                     "requested destination file already exists");
120         }
121         if (getAvailableBytes(getFilesystemRoot(path)) < contentLength) {
122             throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
123                     "insufficient space on external storage");
124         }
125
126         return path;
127     }
128
129     /**
130      * @return the root of the filesystem containing the given path
131      */
132     public static File getFilesystemRoot(String path) {
133         File cache = Environment.getDownloadCacheDirectory();
134         if (path.startsWith(cache.getPath())) {
135             return cache;
136         }
137         File external = Environment.getExternalStorageDirectory();
138         if (path.startsWith(external.getPath())) {
139             return external;
140         }
141         throw new IllegalArgumentException("Cannot determine filesystem root for " + path);
142     }
143
144     private static String chooseFullPath(Context context, String url, String hint,
145                                          String contentDisposition, String contentLocation,
146                                          String mimeType, int destination, long contentLength)
147             throws GenerateSaveFileError {
148         File base = locateDestinationDirectory(context, mimeType, destination, contentLength);
149         String filename = chooseFilename(url, hint, contentDisposition, contentLocation,
150                                          destination);
151
152         // Split filename between base and extension
153         // Add an extension if filename does not have one
154         String extension = null;
155         int dotIndex = filename.indexOf('.');
156         if (dotIndex < 0) {
157             extension = chooseExtensionFromMimeType(mimeType, true);
158         } else {
159             extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex);
160             filename = filename.substring(0, dotIndex);
161         }
162
163         boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension);
164
165         filename = base.getPath() + File.separator + filename;
166
167         if (Constants.LOGVV) {
168             Log.v(Constants.TAG, "target file: " + filename + extension);
169         }
170
171         return chooseUniqueFilename(destination, filename, extension, recoveryDir);
172     }
173
174     private static void checkCanHandleDownload(Context context, String mimeType, int destination,
175             boolean isPublicApi) throws GenerateSaveFileError {
176         if (isPublicApi) {
177             return;
178         }
179
180         if (destination == Downloads.Impl.DESTINATION_EXTERNAL
181                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) {
182             if (mimeType == null) {
183                 throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
184                         "external download with no mime type not allowed");
185             }
186             if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
187                 // Check to see if we are allowed to download this file. Only files
188                 // that can be handled by the platform can be downloaded.
189                 // special case DRM files, which we should always allow downloading.
190                 Intent intent = new Intent(Intent.ACTION_VIEW);
191
192                 // We can provide data as either content: or file: URIs,
193                 // so allow both.  (I think it would be nice if we just did
194                 // everything as content: URIs)
195                 // Actually, right now the download manager's UId restrictions
196                 // prevent use from using content: so it's got to be file: or
197                 // nothing
198
199                 PackageManager pm = context.getPackageManager();
200                 intent.setDataAndType(Uri.fromParts("file", "", null), mimeType);
201                 ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
202                 //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list);
203
204                 if (ri == null) {
205                     if (Constants.LOGV) {
206                         Log.v(Constants.TAG, "no handler found for type " + mimeType);
207                     }
208                     throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE,
209                             "no handler found for this download type");
210                 }
211             }
212         }
213     }
214
215     private static File locateDestinationDirectory(Context context, String mimeType,
216                                                    int destination, long contentLength)
217             throws GenerateSaveFileError {
218         // DRM messages should be temporarily stored internally and then passed to
219         // the DRM content provider
220         if (destination == Downloads.Impl.DESTINATION_CACHE_PARTITION
221                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE
222                 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING
223                 || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) {
224             return getCacheDestination(context, contentLength);
225         }
226
227         return getExternalDestination(contentLength);
228     }
229
230     private static File getExternalDestination(long contentLength) throws GenerateSaveFileError {
231         if (!isExternalMediaMounted()) {
232             throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR,
233                     "external media not mounted");
234         }
235
236         File root = Environment.getExternalStorageDirectory();
237         if (getAvailableBytes(root) < contentLength) {
238             // Insufficient space.
239             Log.d(Constants.TAG, "download aborted - not enough free space");
240             throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
241                     "insufficient space on external media");
242         }
243
244         File base = new File(root.getPath() + Constants.DEFAULT_DL_SUBDIR);
245         if (!base.isDirectory() && !base.mkdir()) {
246             // Can't create download directory, e.g. because a file called "download"
247             // already exists at the root level, or the SD card filesystem is read-only.
248             throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR,
249                     "unable to create external downloads directory " + base.getPath());
250         }
251         return base;
252     }
253
254     public static boolean isExternalMediaMounted() {
255         if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
256             // No SD card found.
257             Log.d(Constants.TAG, "no external storage");
258             return false;
259         }
260         return true;
261     }
262
263     private static File getCacheDestination(Context context, long contentLength)
264             throws GenerateSaveFileError {
265         File base;
266         base = Environment.getDownloadCacheDirectory();
267         long bytesAvailable = getAvailableBytes(base);
268         while (bytesAvailable < contentLength) {
269             // Insufficient space; try discarding purgeable files.
270             if (!discardPurgeableFiles(context, contentLength - bytesAvailable)) {
271                 // No files to purge, give up.
272                 throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR,
273                         "not enough free space in internal download storage, unable to free any "
274                         + "more");
275             }
276             bytesAvailable = getAvailableBytes(base);
277         }
278         return base;
279     }
280
281     /**
282      * @return the number of bytes available on the filesystem rooted at the given File
283      */
284     public static long getAvailableBytes(File root) {
285         StatFs stat = new StatFs(root.getPath());
286         // put a bit of margin (in case creating the file grows the system by a few blocks)
287         long availableBlocks = (long) stat.getAvailableBlocks() - 4;
288         return stat.getBlockSize() * availableBlocks;
289     }
290
291     private static String chooseFilename(String url, String hint, String contentDisposition,
292             String contentLocation, int destination) {
293         String filename = null;
294
295         // First, try to use the hint from the application, if there's one
296         if (filename == null && hint != null && !hint.endsWith("/")) {
297             if (Constants.LOGVV) {
298                 Log.v(Constants.TAG, "getting filename from hint");
299             }
300             int index = hint.lastIndexOf('/') + 1;
301             if (index > 0) {
302                 filename = hint.substring(index);
303             } else {
304                 filename = hint;
305             }
306         }
307
308         // If we couldn't do anything with the hint, move toward the content disposition
309         if (filename == null && contentDisposition != null) {
310             filename = parseContentDisposition(contentDisposition);
311             if (filename != null) {
312                 if (Constants.LOGVV) {
313                     Log.v(Constants.TAG, "getting filename from content-disposition");
314                 }
315                 int index = filename.lastIndexOf('/') + 1;
316                 if (index > 0) {
317                     filename = filename.substring(index);
318                 }
319             }
320         }
321
322         // If we still have nothing at this point, try the content location
323         if (filename == null && contentLocation != null) {
324             String decodedContentLocation = Uri.decode(contentLocation);
325             if (decodedContentLocation != null
326                     && !decodedContentLocation.endsWith("/")
327                     && decodedContentLocation.indexOf('?') < 0) {
328                 if (Constants.LOGVV) {
329                     Log.v(Constants.TAG, "getting filename from content-location");
330                 }
331                 int index = decodedContentLocation.lastIndexOf('/') + 1;
332                 if (index > 0) {
333                     filename = decodedContentLocation.substring(index);
334                 } else {
335                     filename = decodedContentLocation;
336                 }
337             }
338         }
339
340         // If all the other http-related approaches failed, use the plain uri
341         if (filename == null) {
342             String decodedUrl = Uri.decode(url);
343             if (decodedUrl != null
344                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
345                 int index = decodedUrl.lastIndexOf('/') + 1;
346                 if (index > 0) {
347                     if (Constants.LOGVV) {
348                         Log.v(Constants.TAG, "getting filename from uri");
349                     }
350                     filename = decodedUrl.substring(index);
351                 }
352             }
353         }
354
355         // Finally, if couldn't get filename from URI, get a generic filename
356         if (filename == null) {
357             if (Constants.LOGVV) {
358                 Log.v(Constants.TAG, "using default filename");
359             }
360             filename = Constants.DEFAULT_DL_FILENAME;
361         }
362
363         filename = filename.replaceAll("[^a-zA-Z0-9\\.\\-_]+", "_");
364
365
366         return filename;
367     }
368
369     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
370         String extension = null;
371         if (mimeType != null) {
372             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
373             if (extension != null) {
374                 if (Constants.LOGVV) {
375                     Log.v(Constants.TAG, "adding extension from type");
376                 }
377                 extension = "." + extension;
378             } else {
379                 if (Constants.LOGVV) {
380                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
381                 }
382             }
383         }
384         if (extension == null) {
385             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
386                 if (mimeType.equalsIgnoreCase("text/html")) {
387                     if (Constants.LOGVV) {
388                         Log.v(Constants.TAG, "adding default html extension");
389                     }
390                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
391                 } else if (useDefaults) {
392                     if (Constants.LOGVV) {
393                         Log.v(Constants.TAG, "adding default text extension");
394                     }
395                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
396                 }
397             } else if (useDefaults) {
398                 if (Constants.LOGVV) {
399                     Log.v(Constants.TAG, "adding default binary extension");
400                 }
401                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
402             }
403         }
404         return extension;
405     }
406
407     private static String chooseExtensionFromFilename(String mimeType, int destination,
408             String filename, int dotIndex) {
409         String extension = null;
410         if (mimeType != null) {
411             // Compare the last segment of the extension against the mime type.
412             // If there's a mismatch, discard the entire extension.
413             int lastDotIndex = filename.lastIndexOf('.');
414             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
415                     filename.substring(lastDotIndex + 1));
416             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
417                 extension = chooseExtensionFromMimeType(mimeType, false);
418                 if (extension != null) {
419                     if (Constants.LOGVV) {
420                         Log.v(Constants.TAG, "substituting extension from type");
421                     }
422                 } else {
423                     if (Constants.LOGVV) {
424                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
425                     }
426                 }
427             }
428         }
429         if (extension == null) {
430             if (Constants.LOGVV) {
431                 Log.v(Constants.TAG, "keeping extension");
432             }
433             extension = filename.substring(dotIndex);
434         }
435         return extension;
436     }
437
438     private static String chooseUniqueFilename(int destination, String filename,
439             String extension, boolean recoveryDir) throws GenerateSaveFileError {
440         String fullFilename = filename + extension;
441         if (!new File(fullFilename).exists()
442                 && (!recoveryDir ||
443                 (destination != Downloads.Impl.DESTINATION_CACHE_PARTITION &&
444                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE &&
445                         destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING))) {
446             return fullFilename;
447         }
448         filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR;
449         /*
450         * This number is used to generate partially randomized filenames to avoid
451         * collisions.
452         * It starts at 1.
453         * The next 9 iterations increment it by 1 at a time (up to 10).
454         * The next 9 iterations increment it by 1 to 10 (random) at a time.
455         * The next 9 iterations increment it by 1 to 100 (random) at a time.
456         * ... Up to the point where it increases by 100000000 at a time.
457         * (the maximum value that can be reached is 1000000000)
458         * As soon as a number is reached that generates a filename that doesn't exist,
459         *     that filename is used.
460         * If the filename coming in is [base].[ext], the generated filenames are
461         *     [base]-[sequence].[ext].
462         */
463         int sequence = 1;
464         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
465             for (int iteration = 0; iteration < 9; ++iteration) {
466                 fullFilename = filename + sequence + extension;
467                 if (!new File(fullFilename).exists()) {
468                     return fullFilename;
469                 }
470                 if (Constants.LOGVV) {
471                     Log.v(Constants.TAG, "file with sequence number " + sequence + " exists");
472                 }
473                 sequence += sRandom.nextInt(magnitude) + 1;
474             }
475         }
476         throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR,
477                 "failed to generate an unused filename on internal download storage");
478     }
479
480     /**
481      * Deletes purgeable files from the cache partition. This also deletes
482      * the matching database entries. Files are deleted in LRU order until
483      * the total byte size is greater than targetBytes.
484      */
485     public static final boolean discardPurgeableFiles(Context context, long targetBytes) {
486         Cursor cursor = context.getContentResolver().query(
487                 Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
488                 null,
489                 "( " +
490                 Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " +
491                 Downloads.Impl.COLUMN_DESTINATION +
492                         " = '" + Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE + "' )",
493                 null,
494                 Downloads.Impl.COLUMN_LAST_MODIFICATION);
495         if (cursor == null) {
496             return false;
497         }
498         long totalFreed = 0;
499         try {
500             cursor.moveToFirst();
501             while (!cursor.isAfterLast() && totalFreed < targetBytes) {
502                 File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA)));
503                 if (Constants.LOGVV) {
504                     Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " +
505                             file.length() + " bytes");
506                 }
507                 totalFreed += file.length();
508                 file.delete();
509                 long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID));
510                 context.getContentResolver().delete(
511                         ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id),
512                         null, null);
513                 cursor.moveToNext();
514             }
515         } finally {
516             cursor.close();
517         }
518         if (Constants.LOGV) {
519             if (totalFreed > 0) {
520                 Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " +
521                         targetBytes + " requested");
522             }
523         }
524         return totalFreed > 0;
525     }
526
527     /**
528      * Returns whether the network is available
529      */
530     public static boolean isNetworkAvailable(SystemFacade system) {
531         return system.getActiveNetworkType() != null;
532     }
533
534     /**
535      * Checks whether the filename looks legitimate
536      */
537     public static boolean isFilenameValid(String filename) {
538         filename = filename.replaceFirst("/+", "/"); // normalize leading slashes
539         return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
540                 || filename.startsWith(Environment.getExternalStorageDirectory().toString());
541     }
542
543     /**
544      * Checks whether this looks like a legitimate selection parameter
545      */
546     public static void validateSelection(String selection, Set<String> allowedColumns) {
547         try {
548             if (selection == null || selection.isEmpty()) {
549                 return;
550             }
551             Lexer lexer = new Lexer(selection, allowedColumns);
552             parseExpression(lexer);
553             if (lexer.currentToken() != Lexer.TOKEN_END) {
554                 throw new IllegalArgumentException("syntax error");
555             }
556         } catch (RuntimeException ex) {
557             if (Constants.LOGV) {
558                 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
559             } else if (Config.LOGD) {
560                 Log.d(Constants.TAG, "invalid selection triggered " + ex);
561             }
562             throw ex;
563         }
564
565     }
566
567     // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
568     //             | statement [AND_OR expression]*
569     private static void parseExpression(Lexer lexer) {
570         for (;;) {
571             // ( expression )
572             if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
573                 lexer.advance();
574                 parseExpression(lexer);
575                 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
576                     throw new IllegalArgumentException("syntax error, unmatched parenthese");
577                 }
578                 lexer.advance();
579             } else {
580                 // statement
581                 parseStatement(lexer);
582             }
583             if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
584                 break;
585             }
586             lexer.advance();
587         }
588     }
589
590     // statement <- COLUMN COMPARE VALUE
591     //            | COLUMN IS NULL
592     private static void parseStatement(Lexer lexer) {
593         // both possibilities start with COLUMN
594         if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
595             throw new IllegalArgumentException("syntax error, expected column name");
596         }
597         lexer.advance();
598
599         // statement <- COLUMN COMPARE VALUE
600         if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
601             lexer.advance();
602             if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
603                 throw new IllegalArgumentException("syntax error, expected quoted string");
604             }
605             lexer.advance();
606             return;
607         }
608
609         // statement <- COLUMN IS NULL
610         if (lexer.currentToken() == Lexer.TOKEN_IS) {
611             lexer.advance();
612             if (lexer.currentToken() != Lexer.TOKEN_NULL) {
613                 throw new IllegalArgumentException("syntax error, expected NULL");
614             }
615             lexer.advance();
616             return;
617         }
618
619         // didn't get anything good after COLUMN
620         throw new IllegalArgumentException("syntax error after column name");
621     }
622
623     /**
624      * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
625      */
626     private static class Lexer {
627         public static final int TOKEN_START = 0;
628         public static final int TOKEN_OPEN_PAREN = 1;
629         public static final int TOKEN_CLOSE_PAREN = 2;
630         public static final int TOKEN_AND_OR = 3;
631         public static final int TOKEN_COLUMN = 4;
632         public static final int TOKEN_COMPARE = 5;
633         public static final int TOKEN_VALUE = 6;
634         public static final int TOKEN_IS = 7;
635         public static final int TOKEN_NULL = 8;
636         public static final int TOKEN_END = 9;
637
638         private final String mSelection;
639         private final Set<String> mAllowedColumns;
640         private int mOffset = 0;
641         private int mCurrentToken = TOKEN_START;
642         private final char[] mChars;
643
644         public Lexer(String selection, Set<String> allowedColumns) {
645             mSelection = selection;
646             mAllowedColumns = allowedColumns;
647             mChars = new char[mSelection.length()];
648             mSelection.getChars(0, mChars.length, mChars, 0);
649             advance();
650         }
651
652         public int currentToken() {
653             return mCurrentToken;
654         }
655
656         public void advance() {
657             char[] chars = mChars;
658
659             // consume whitespace
660             while (mOffset < chars.length && chars[mOffset] == ' ') {
661                 ++mOffset;
662             }
663
664             // end of input
665             if (mOffset == chars.length) {
666                 mCurrentToken = TOKEN_END;
667                 return;
668             }
669
670             // "("
671             if (chars[mOffset] == '(') {
672                 ++mOffset;
673                 mCurrentToken = TOKEN_OPEN_PAREN;
674                 return;
675             }
676
677             // ")"
678             if (chars[mOffset] == ')') {
679                 ++mOffset;
680                 mCurrentToken = TOKEN_CLOSE_PAREN;
681                 return;
682             }
683
684             // "?"
685             if (chars[mOffset] == '?') {
686                 ++mOffset;
687                 mCurrentToken = TOKEN_VALUE;
688                 return;
689             }
690
691             // "=" and "=="
692             if (chars[mOffset] == '=') {
693                 ++mOffset;
694                 mCurrentToken = TOKEN_COMPARE;
695                 if (mOffset < chars.length && chars[mOffset] == '=') {
696                     ++mOffset;
697                 }
698                 return;
699             }
700
701             // ">" and ">="
702             if (chars[mOffset] == '>') {
703                 ++mOffset;
704                 mCurrentToken = TOKEN_COMPARE;
705                 if (mOffset < chars.length && chars[mOffset] == '=') {
706                     ++mOffset;
707                 }
708                 return;
709             }
710
711             // "<", "<=" and "<>"
712             if (chars[mOffset] == '<') {
713                 ++mOffset;
714                 mCurrentToken = TOKEN_COMPARE;
715                 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
716                     ++mOffset;
717                 }
718                 return;
719             }
720
721             // "!="
722             if (chars[mOffset] == '!') {
723                 ++mOffset;
724                 mCurrentToken = TOKEN_COMPARE;
725                 if (mOffset < chars.length && chars[mOffset] == '=') {
726                     ++mOffset;
727                     return;
728                 }
729                 throw new IllegalArgumentException("Unexpected character after !");
730             }
731
732             // columns and keywords
733             // first look for anything that looks like an identifier or a keyword
734             //     and then recognize the individual words.
735             // no attempt is made at discarding sequences of underscores with no alphanumeric
736             //     characters, even though it's not clear that they'd be legal column names.
737             if (isIdentifierStart(chars[mOffset])) {
738                 int startOffset = mOffset;
739                 ++mOffset;
740                 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
741                     ++mOffset;
742                 }
743                 String word = mSelection.substring(startOffset, mOffset);
744                 if (mOffset - startOffset <= 4) {
745                     if (word.equals("IS")) {
746                         mCurrentToken = TOKEN_IS;
747                         return;
748                     }
749                     if (word.equals("OR") || word.equals("AND")) {
750                         mCurrentToken = TOKEN_AND_OR;
751                         return;
752                     }
753                     if (word.equals("NULL")) {
754                         mCurrentToken = TOKEN_NULL;
755                         return;
756                     }
757                 }
758                 if (mAllowedColumns.contains(word)) {
759                     mCurrentToken = TOKEN_COLUMN;
760                     return;
761                 }
762                 throw new IllegalArgumentException("unrecognized column or keyword");
763             }
764
765             // quoted strings
766             if (chars[mOffset] == '\'') {
767                 ++mOffset;
768                 while (mOffset < chars.length) {
769                     if (chars[mOffset] == '\'') {
770                         if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
771                             ++mOffset;
772                         } else {
773                             break;
774                         }
775                     }
776                     ++mOffset;
777                 }
778                 if (mOffset == chars.length) {
779                     throw new IllegalArgumentException("unterminated string");
780                 }
781                 ++mOffset;
782                 mCurrentToken = TOKEN_VALUE;
783                 return;
784             }
785
786             // anything we don't recognize
787             throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
788         }
789
790         private static final boolean isIdentifierStart(char c) {
791             return c == '_' ||
792                     (c >= 'A' && c <= 'Z') ||
793                     (c >= 'a' && c <= 'z');
794         }
795
796         private static final boolean isIdentifierChar(char c) {
797             return c == '_' ||
798                     (c >= 'A' && c <= 'Z') ||
799                     (c >= 'a' && c <= 'z') ||
800                     (c >= '0' && c <= '9');
801         }
802     }
803
804     /**
805      * Delete the given file from device
806      * and delete its row from the downloads database.
807      */
808     /* package */ static void deleteFile(ContentResolver resolver, long id, String path, String mimeType) {
809         try {
810             File file = new File(path);
811             file.delete();
812         } catch (Exception e) {
813             Log.w(Constants.TAG, "file: '" + path + "' couldn't be deleted", e);
814         }
815         resolver.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, Downloads.Impl._ID + " = ? ",
816                 new String[]{String.valueOf(id)});
817     }
818 }