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