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