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