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