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