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