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