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