Helpers.java revision caab8ef3d5eac4b64aef7042078a542bb52f3bda
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.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}
838