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