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