Helpers.java revision 176a74426f750dc56e7d200a4cdc3b6ed75fe6cd
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
17package com.android.providers.downloads;
18
19import android.content.ContentUris;
20import android.content.Context;
21import android.content.Intent;
22import android.content.pm.PackageManager;
23import android.content.pm.ResolveInfo;
24import android.database.Cursor;
25import android.drm.mobile1.DrmRawContent;
26import android.net.Uri;
27import android.os.Environment;
28import android.os.StatFs;
29import android.os.SystemClock;
30import android.provider.Downloads;
31import android.util.Config;
32import android.util.Log;
33import android.webkit.MimeTypeMap;
34
35import java.io.File;
36import java.io.FileNotFoundException;
37import java.io.FileOutputStream;
38import java.util.Random;
39import java.util.Set;
40import java.util.regex.Matcher;
41import java.util.regex.Pattern;
42
43/**
44 * Some helper functions for the download manager
45 */
46public 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.ALL_DOWNLOADS_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.ALL_DOWNLOADS_CONTENT_URI, id),
498                        null, null);
499                cursor.moveToNext();
500            }
501        } finally {
502            cursor.close();
503        }
504        if (Constants.LOGV) {
505            if (totalFreed > 0) {
506                Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " +
507                        targetBytes + " requested");
508            }
509        }
510        return totalFreed > 0;
511    }
512
513    /**
514     * Returns whether the network is available
515     */
516    public static boolean isNetworkAvailable(SystemFacade system) {
517        return system.getActiveNetworkType() != null;
518    }
519
520    /**
521     * Checks whether the filename looks legitimate
522     */
523    public static boolean isFilenameValid(String filename) {
524        filename = filename.replaceFirst("/+", "/"); // normalize leading slashes
525        return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
526                || filename.startsWith(Environment.getExternalStorageDirectory().toString());
527    }
528
529    /**
530     * Checks whether this looks like a legitimate selection parameter
531     */
532    public static void validateSelection(String selection, Set<String> allowedColumns) {
533        try {
534            if (selection == null || selection.isEmpty()) {
535                return;
536            }
537            Lexer lexer = new Lexer(selection, allowedColumns);
538            parseExpression(lexer);
539            if (lexer.currentToken() != Lexer.TOKEN_END) {
540                throw new IllegalArgumentException("syntax error");
541            }
542        } catch (RuntimeException ex) {
543            if (Constants.LOGV) {
544                Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
545            } else if (Config.LOGD) {
546                Log.d(Constants.TAG, "invalid selection triggered " + ex);
547            }
548            throw ex;
549        }
550
551    }
552
553    // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
554    //             | statement [AND_OR expression]*
555    private static void parseExpression(Lexer lexer) {
556        for (;;) {
557            // ( expression )
558            if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
559                lexer.advance();
560                parseExpression(lexer);
561                if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
562                    throw new IllegalArgumentException("syntax error, unmatched parenthese");
563                }
564                lexer.advance();
565            } else {
566                // statement
567                parseStatement(lexer);
568            }
569            if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
570                break;
571            }
572            lexer.advance();
573        }
574    }
575
576    // statement <- COLUMN COMPARE VALUE
577    //            | COLUMN IS NULL
578    private static void parseStatement(Lexer lexer) {
579        // both possibilities start with COLUMN
580        if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
581            throw new IllegalArgumentException("syntax error, expected column name");
582        }
583        lexer.advance();
584
585        // statement <- COLUMN COMPARE VALUE
586        if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
587            lexer.advance();
588            if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
589                throw new IllegalArgumentException("syntax error, expected quoted string");
590            }
591            lexer.advance();
592            return;
593        }
594
595        // statement <- COLUMN IS NULL
596        if (lexer.currentToken() == Lexer.TOKEN_IS) {
597            lexer.advance();
598            if (lexer.currentToken() != Lexer.TOKEN_NULL) {
599                throw new IllegalArgumentException("syntax error, expected NULL");
600            }
601            lexer.advance();
602            return;
603        }
604
605        // didn't get anything good after COLUMN
606        throw new IllegalArgumentException("syntax error after column name");
607    }
608
609    /**
610     * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
611     */
612    private static class Lexer {
613        public static final int TOKEN_START = 0;
614        public static final int TOKEN_OPEN_PAREN = 1;
615        public static final int TOKEN_CLOSE_PAREN = 2;
616        public static final int TOKEN_AND_OR = 3;
617        public static final int TOKEN_COLUMN = 4;
618        public static final int TOKEN_COMPARE = 5;
619        public static final int TOKEN_VALUE = 6;
620        public static final int TOKEN_IS = 7;
621        public static final int TOKEN_NULL = 8;
622        public static final int TOKEN_END = 9;
623
624        private final String mSelection;
625        private final Set<String> mAllowedColumns;
626        private int mOffset = 0;
627        private int mCurrentToken = TOKEN_START;
628        private final char[] mChars;
629
630        public Lexer(String selection, Set<String> allowedColumns) {
631            mSelection = selection;
632            mAllowedColumns = allowedColumns;
633            mChars = new char[mSelection.length()];
634            mSelection.getChars(0, mChars.length, mChars, 0);
635            advance();
636        }
637
638        public int currentToken() {
639            return mCurrentToken;
640        }
641
642        public void advance() {
643            char[] chars = mChars;
644
645            // consume whitespace
646            while (mOffset < chars.length && chars[mOffset] == ' ') {
647                ++mOffset;
648            }
649
650            // end of input
651            if (mOffset == chars.length) {
652                mCurrentToken = TOKEN_END;
653                return;
654            }
655
656            // "("
657            if (chars[mOffset] == '(') {
658                ++mOffset;
659                mCurrentToken = TOKEN_OPEN_PAREN;
660                return;
661            }
662
663            // ")"
664            if (chars[mOffset] == ')') {
665                ++mOffset;
666                mCurrentToken = TOKEN_CLOSE_PAREN;
667                return;
668            }
669
670            // "?"
671            if (chars[mOffset] == '?') {
672                ++mOffset;
673                mCurrentToken = TOKEN_VALUE;
674                return;
675            }
676
677            // "=" and "=="
678            if (chars[mOffset] == '=') {
679                ++mOffset;
680                mCurrentToken = TOKEN_COMPARE;
681                if (mOffset < chars.length && chars[mOffset] == '=') {
682                    ++mOffset;
683                }
684                return;
685            }
686
687            // ">" and ">="
688            if (chars[mOffset] == '>') {
689                ++mOffset;
690                mCurrentToken = TOKEN_COMPARE;
691                if (mOffset < chars.length && chars[mOffset] == '=') {
692                    ++mOffset;
693                }
694                return;
695            }
696
697            // "<", "<=" and "<>"
698            if (chars[mOffset] == '<') {
699                ++mOffset;
700                mCurrentToken = TOKEN_COMPARE;
701                if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
702                    ++mOffset;
703                }
704                return;
705            }
706
707            // "!="
708            if (chars[mOffset] == '!') {
709                ++mOffset;
710                mCurrentToken = TOKEN_COMPARE;
711                if (mOffset < chars.length && chars[mOffset] == '=') {
712                    ++mOffset;
713                    return;
714                }
715                throw new IllegalArgumentException("Unexpected character after !");
716            }
717
718            // columns and keywords
719            // first look for anything that looks like an identifier or a keyword
720            //     and then recognize the individual words.
721            // no attempt is made at discarding sequences of underscores with no alphanumeric
722            //     characters, even though it's not clear that they'd be legal column names.
723            if (isIdentifierStart(chars[mOffset])) {
724                int startOffset = mOffset;
725                ++mOffset;
726                while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
727                    ++mOffset;
728                }
729                String word = mSelection.substring(startOffset, mOffset);
730                if (mOffset - startOffset <= 4) {
731                    if (word.equals("IS")) {
732                        mCurrentToken = TOKEN_IS;
733                        return;
734                    }
735                    if (word.equals("OR") || word.equals("AND")) {
736                        mCurrentToken = TOKEN_AND_OR;
737                        return;
738                    }
739                    if (word.equals("NULL")) {
740                        mCurrentToken = TOKEN_NULL;
741                        return;
742                    }
743                }
744                if (mAllowedColumns.contains(word)) {
745                    mCurrentToken = TOKEN_COLUMN;
746                    return;
747                }
748                throw new IllegalArgumentException("unrecognized column or keyword");
749            }
750
751            // quoted strings
752            if (chars[mOffset] == '\'') {
753                ++mOffset;
754                while (mOffset < chars.length) {
755                    if (chars[mOffset] == '\'') {
756                        if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
757                            ++mOffset;
758                        } else {
759                            break;
760                        }
761                    }
762                    ++mOffset;
763                }
764                if (mOffset == chars.length) {
765                    throw new IllegalArgumentException("unterminated string");
766                }
767                ++mOffset;
768                mCurrentToken = TOKEN_VALUE;
769                return;
770            }
771
772            // anything we don't recognize
773            throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
774        }
775
776        private static final boolean isIdentifierStart(char c) {
777            return c == '_' ||
778                    (c >= 'A' && c <= 'Z') ||
779                    (c >= 'a' && c <= 'z');
780        }
781
782        private static final boolean isIdentifierChar(char c) {
783            return c == '_' ||
784                    (c >= 'A' && c <= 'Z') ||
785                    (c >= 'a' && c <= 'z') ||
786                    (c >= '0' && c <= '9');
787        }
788    }
789
790    /**
791     * Replace invalid filename characters according to
792     * specifications of the VFAT.
793     * @note Package-private due to testing.
794     */
795    private static String replaceInvalidVfatCharacters(String filename) {
796        final char START_CTRLCODE = 0x00;
797        final char END_CTRLCODE = 0x1f;
798        final char QUOTEDBL = 0x22;
799        final char ASTERISK = 0x2A;
800        final char SLASH = 0x2F;
801        final char COLON = 0x3A;
802        final char LESS = 0x3C;
803        final char GREATER = 0x3E;
804        final char QUESTION = 0x3F;
805        final char BACKSLASH = 0x5C;
806        final char BAR = 0x7C;
807        final char DEL = 0x7F;
808        final char UNDERSCORE = 0x5F;
809
810        StringBuffer sb = new StringBuffer();
811        char ch;
812        boolean isRepetition = false;
813        for (int i = 0; i < filename.length(); i++) {
814            ch = filename.charAt(i);
815            if ((START_CTRLCODE <= ch &&
816                ch <= END_CTRLCODE) ||
817                ch == QUOTEDBL ||
818                ch == ASTERISK ||
819                ch == SLASH ||
820                ch == COLON ||
821                ch == LESS ||
822                ch == GREATER ||
823                ch == QUESTION ||
824                ch == BACKSLASH ||
825                ch == BAR ||
826                ch == DEL){
827                if (!isRepetition) {
828                    sb.append(UNDERSCORE);
829                    isRepetition = true;
830                }
831            } else {
832                sb.append(ch);
833                isRepetition = false;
834            }
835        }
836        return sb.toString();
837    }
838}
839