Home | History | Annotate | Download | only in downloads
      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 static android.os.Environment.buildExternalStorageAppCacheDirs;
     20 import static android.os.Environment.buildExternalStorageAppFilesDirs;
     21 import static android.os.Environment.buildExternalStorageAppMediaDirs;
     22 import static android.os.Environment.buildExternalStorageAppObbDirs;
     23 import static android.provider.Downloads.Impl.FLAG_REQUIRES_CHARGING;
     24 import static android.provider.Downloads.Impl.FLAG_REQUIRES_DEVICE_IDLE;
     25 
     26 import static com.android.providers.downloads.Constants.TAG;
     27 
     28 import android.app.job.JobInfo;
     29 import android.app.job.JobScheduler;
     30 import android.content.ComponentName;
     31 import android.content.Context;
     32 import android.database.Cursor;
     33 import android.net.Uri;
     34 import android.os.Environment;
     35 import android.os.FileUtils;
     36 import android.os.Handler;
     37 import android.os.HandlerThread;
     38 import android.os.Process;
     39 import android.os.SystemClock;
     40 import android.os.UserHandle;
     41 import android.os.storage.StorageManager;
     42 import android.os.storage.StorageVolume;
     43 import android.provider.Downloads;
     44 import android.util.Log;
     45 import android.webkit.MimeTypeMap;
     46 
     47 import com.google.common.annotations.VisibleForTesting;
     48 
     49 import java.io.File;
     50 import java.io.IOException;
     51 import java.util.Random;
     52 import java.util.Set;
     53 import java.util.regex.Matcher;
     54 import java.util.regex.Pattern;
     55 
     56 /**
     57  * Some helper functions for the download manager
     58  */
     59 public class Helpers {
     60     public static Random sRandom = new Random(SystemClock.uptimeMillis());
     61 
     62     /** Regex used to parse content-disposition headers */
     63     private static final Pattern CONTENT_DISPOSITION_PATTERN =
     64             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
     65 
     66     private static final Object sUniqueLock = new Object();
     67 
     68     private static HandlerThread sAsyncHandlerThread;
     69     private static Handler sAsyncHandler;
     70 
     71     private static SystemFacade sSystemFacade;
     72     private static DownloadNotifier sNotifier;
     73 
     74     private Helpers() {
     75     }
     76 
     77     public synchronized static Handler getAsyncHandler() {
     78         if (sAsyncHandlerThread == null) {
     79             sAsyncHandlerThread = new HandlerThread("sAsyncHandlerThread",
     80                     Process.THREAD_PRIORITY_BACKGROUND);
     81             sAsyncHandlerThread.start();
     82             sAsyncHandler = new Handler(sAsyncHandlerThread.getLooper());
     83         }
     84         return sAsyncHandler;
     85     }
     86 
     87     @VisibleForTesting
     88     public synchronized static void setSystemFacade(SystemFacade systemFacade) {
     89         sSystemFacade = systemFacade;
     90     }
     91 
     92     public synchronized static SystemFacade getSystemFacade(Context context) {
     93         if (sSystemFacade == null) {
     94             sSystemFacade = new RealSystemFacade(context);
     95         }
     96         return sSystemFacade;
     97     }
     98 
     99     public synchronized static DownloadNotifier getDownloadNotifier(Context context) {
    100         if (sNotifier == null) {
    101             sNotifier = new DownloadNotifier(context);
    102         }
    103         return sNotifier;
    104     }
    105 
    106     public static String getString(Cursor cursor, String col) {
    107         return cursor.getString(cursor.getColumnIndexOrThrow(col));
    108     }
    109 
    110     public static int getInt(Cursor cursor, String col) {
    111         return cursor.getInt(cursor.getColumnIndexOrThrow(col));
    112     }
    113 
    114     public static void scheduleJob(Context context, long downloadId) {
    115         final boolean scheduled = scheduleJob(context,
    116                 DownloadInfo.queryDownloadInfo(context, downloadId));
    117         if (!scheduled) {
    118             // If we didn't schedule a future job, kick off a notification
    119             // update pass immediately
    120             getDownloadNotifier(context).update();
    121         }
    122     }
    123 
    124     /**
    125      * Schedule (or reschedule) a job for the given {@link DownloadInfo} using
    126      * its current state to define job constraints.
    127      */
    128     public static boolean scheduleJob(Context context, DownloadInfo info) {
    129         if (info == null) return false;
    130 
    131         final JobScheduler scheduler = context.getSystemService(JobScheduler.class);
    132 
    133         // Tear down any existing job for this download
    134         final int jobId = (int) info.mId;
    135         scheduler.cancel(jobId);
    136 
    137         // Skip scheduling if download is paused or finished
    138         if (!info.isReadyToSchedule()) return false;
    139 
    140         final JobInfo.Builder builder = new JobInfo.Builder(jobId,
    141                 new ComponentName(context, DownloadJobService.class));
    142 
    143         // When this download will show a notification, run with a higher
    144         // priority, since it's effectively a foreground service
    145         if (info.isVisible()) {
    146             builder.setPriority(JobInfo.PRIORITY_FOREGROUND_APP);
    147             builder.setFlags(JobInfo.FLAG_WILL_BE_FOREGROUND);
    148         }
    149 
    150         // We might have a backoff constraint due to errors
    151         final long latency = info.getMinimumLatency();
    152         if (latency > 0) {
    153             builder.setMinimumLatency(latency);
    154         }
    155 
    156         // We always require a network, but the type of network might be further
    157         // restricted based on download request or user override
    158         builder.setRequiredNetworkType(info.getRequiredNetworkType(info.mTotalBytes));
    159 
    160         if ((info.mFlags & FLAG_REQUIRES_CHARGING) != 0) {
    161             builder.setRequiresCharging(true);
    162         }
    163         if ((info.mFlags & FLAG_REQUIRES_DEVICE_IDLE) != 0) {
    164             builder.setRequiresDeviceIdle(true);
    165         }
    166 
    167         // If package name was filtered during insert (probably due to being
    168         // invalid), blame based on the requesting UID instead
    169         String packageName = info.mPackage;
    170         if (packageName == null) {
    171             packageName = context.getPackageManager().getPackagesForUid(info.mUid)[0];
    172         }
    173 
    174         scheduler.scheduleAsPackage(builder.build(), packageName, UserHandle.myUserId(), TAG);
    175         return true;
    176     }
    177 
    178     /*
    179      * Parse the Content-Disposition HTTP Header. The format of the header
    180      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
    181      * This header provides a filename for content that is going to be
    182      * downloaded to the file system. We only support the attachment type.
    183      */
    184     private static String parseContentDisposition(String contentDisposition) {
    185         try {
    186             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
    187             if (m.find()) {
    188                 return m.group(1);
    189             }
    190         } catch (IllegalStateException ex) {
    191              // This function is defined as returning null when it can't parse the header
    192         }
    193         return null;
    194     }
    195 
    196     /**
    197      * Creates a filename (where the file should be saved) from info about a download.
    198      * This file will be touched to reserve it.
    199      */
    200     static String generateSaveFile(Context context, String url, String hint,
    201             String contentDisposition, String contentLocation, String mimeType, int destination)
    202             throws IOException {
    203 
    204         final File parent;
    205         final File[] parentTest;
    206         String name = null;
    207 
    208         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
    209             final File file = new File(Uri.parse(hint).getPath());
    210             parent = file.getParentFile().getAbsoluteFile();
    211             parentTest = new File[] { parent };
    212             name = file.getName();
    213         } else {
    214             parent = getRunningDestinationDirectory(context, destination);
    215             parentTest = new File[] {
    216                     parent,
    217                     getSuccessDestinationDirectory(context, destination)
    218             };
    219             name = chooseFilename(url, hint, contentDisposition, contentLocation);
    220         }
    221 
    222         // Ensure target directories are ready
    223         for (File test : parentTest) {
    224             if (!(test.isDirectory() || test.mkdirs())) {
    225                 throw new IOException("Failed to create parent for " + test);
    226             }
    227         }
    228 
    229         if (DownloadDrmHelper.isDrmConvertNeeded(mimeType)) {
    230             name = DownloadDrmHelper.modifyDrmFwLockFileExtension(name);
    231         }
    232 
    233         final String prefix;
    234         final String suffix;
    235         final int dotIndex = name.lastIndexOf('.');
    236         final boolean missingExtension = dotIndex < 0;
    237         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
    238             // Destination is explicitly set - do not change the extension
    239             if (missingExtension) {
    240                 prefix = name;
    241                 suffix = "";
    242             } else {
    243                 prefix = name.substring(0, dotIndex);
    244                 suffix = name.substring(dotIndex);
    245             }
    246         } else {
    247             // Split filename between base and extension
    248             // Add an extension if filename does not have one
    249             if (missingExtension) {
    250                 prefix = name;
    251                 suffix = chooseExtensionFromMimeType(mimeType, true);
    252             } else {
    253                 prefix = name.substring(0, dotIndex);
    254                 suffix = chooseExtensionFromFilename(mimeType, destination, name, dotIndex);
    255             }
    256         }
    257 
    258         synchronized (sUniqueLock) {
    259             name = generateAvailableFilenameLocked(parentTest, prefix, suffix);
    260 
    261             // Claim this filename inside lock to prevent other threads from
    262             // clobbering us. We're not paranoid enough to use O_EXCL.
    263             final File file = new File(parent, name);
    264             file.createNewFile();
    265             return file.getAbsolutePath();
    266         }
    267     }
    268 
    269     private static String chooseFilename(String url, String hint, String contentDisposition,
    270             String contentLocation) {
    271         String filename = null;
    272 
    273         // First, try to use the hint from the application, if there's one
    274         if (filename == null && hint != null && !hint.endsWith("/")) {
    275             if (Constants.LOGVV) {
    276                 Log.v(Constants.TAG, "getting filename from hint");
    277             }
    278             int index = hint.lastIndexOf('/') + 1;
    279             if (index > 0) {
    280                 filename = hint.substring(index);
    281             } else {
    282                 filename = hint;
    283             }
    284         }
    285 
    286         // If we couldn't do anything with the hint, move toward the content disposition
    287         if (filename == null && contentDisposition != null) {
    288             filename = parseContentDisposition(contentDisposition);
    289             if (filename != null) {
    290                 if (Constants.LOGVV) {
    291                     Log.v(Constants.TAG, "getting filename from content-disposition");
    292                 }
    293                 int index = filename.lastIndexOf('/') + 1;
    294                 if (index > 0) {
    295                     filename = filename.substring(index);
    296                 }
    297             }
    298         }
    299 
    300         // If we still have nothing at this point, try the content location
    301         if (filename == null && contentLocation != null) {
    302             String decodedContentLocation = Uri.decode(contentLocation);
    303             if (decodedContentLocation != null
    304                     && !decodedContentLocation.endsWith("/")
    305                     && decodedContentLocation.indexOf('?') < 0) {
    306                 if (Constants.LOGVV) {
    307                     Log.v(Constants.TAG, "getting filename from content-location");
    308                 }
    309                 int index = decodedContentLocation.lastIndexOf('/') + 1;
    310                 if (index > 0) {
    311                     filename = decodedContentLocation.substring(index);
    312                 } else {
    313                     filename = decodedContentLocation;
    314                 }
    315             }
    316         }
    317 
    318         // If all the other http-related approaches failed, use the plain uri
    319         if (filename == null) {
    320             String decodedUrl = Uri.decode(url);
    321             if (decodedUrl != null
    322                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
    323                 int index = decodedUrl.lastIndexOf('/') + 1;
    324                 if (index > 0) {
    325                     if (Constants.LOGVV) {
    326                         Log.v(Constants.TAG, "getting filename from uri");
    327                     }
    328                     filename = decodedUrl.substring(index);
    329                 }
    330             }
    331         }
    332 
    333         // Finally, if couldn't get filename from URI, get a generic filename
    334         if (filename == null) {
    335             if (Constants.LOGVV) {
    336                 Log.v(Constants.TAG, "using default filename");
    337             }
    338             filename = Constants.DEFAULT_DL_FILENAME;
    339         }
    340 
    341         // The VFAT file system is assumed as target for downloads.
    342         // Replace invalid characters according to the specifications of VFAT.
    343         filename = FileUtils.buildValidFatFilename(filename);
    344 
    345         return filename;
    346     }
    347 
    348     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
    349         String extension = null;
    350         if (mimeType != null) {
    351             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
    352             if (extension != null) {
    353                 if (Constants.LOGVV) {
    354                     Log.v(Constants.TAG, "adding extension from type");
    355                 }
    356                 extension = "." + extension;
    357             } else {
    358                 if (Constants.LOGVV) {
    359                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
    360                 }
    361             }
    362         }
    363         if (extension == null) {
    364             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
    365                 if (mimeType.equalsIgnoreCase("text/html")) {
    366                     if (Constants.LOGVV) {
    367                         Log.v(Constants.TAG, "adding default html extension");
    368                     }
    369                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
    370                 } else if (useDefaults) {
    371                     if (Constants.LOGVV) {
    372                         Log.v(Constants.TAG, "adding default text extension");
    373                     }
    374                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
    375                 }
    376             } else if (useDefaults) {
    377                 if (Constants.LOGVV) {
    378                     Log.v(Constants.TAG, "adding default binary extension");
    379                 }
    380                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
    381             }
    382         }
    383         return extension;
    384     }
    385 
    386     private static String chooseExtensionFromFilename(String mimeType, int destination,
    387             String filename, int lastDotIndex) {
    388         String extension = null;
    389         if (mimeType != null) {
    390             // Compare the last segment of the extension against the mime type.
    391             // If there's a mismatch, discard the entire extension.
    392             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
    393                     filename.substring(lastDotIndex + 1));
    394             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
    395                 extension = chooseExtensionFromMimeType(mimeType, false);
    396                 if (extension != null) {
    397                     if (Constants.LOGVV) {
    398                         Log.v(Constants.TAG, "substituting extension from type");
    399                     }
    400                 } else {
    401                     if (Constants.LOGVV) {
    402                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
    403                     }
    404                 }
    405             }
    406         }
    407         if (extension == null) {
    408             if (Constants.LOGVV) {
    409                 Log.v(Constants.TAG, "keeping extension");
    410             }
    411             extension = filename.substring(lastDotIndex);
    412         }
    413         return extension;
    414     }
    415 
    416     private static boolean isFilenameAvailableLocked(File[] parents, String name) {
    417         if (Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(name)) return false;
    418 
    419         for (File parent : parents) {
    420             if (new File(parent, name).exists()) {
    421                 return false;
    422             }
    423         }
    424 
    425         return true;
    426     }
    427 
    428     private static String generateAvailableFilenameLocked(
    429             File[] parents, String prefix, String suffix) throws IOException {
    430         String name = prefix + suffix;
    431         if (isFilenameAvailableLocked(parents, name)) {
    432             return name;
    433         }
    434 
    435         /*
    436         * This number is used to generate partially randomized filenames to avoid
    437         * collisions.
    438         * It starts at 1.
    439         * The next 9 iterations increment it by 1 at a time (up to 10).
    440         * The next 9 iterations increment it by 1 to 10 (random) at a time.
    441         * The next 9 iterations increment it by 1 to 100 (random) at a time.
    442         * ... Up to the point where it increases by 100000000 at a time.
    443         * (the maximum value that can be reached is 1000000000)
    444         * As soon as a number is reached that generates a filename that doesn't exist,
    445         *     that filename is used.
    446         * If the filename coming in is [base].[ext], the generated filenames are
    447         *     [base]-[sequence].[ext].
    448         */
    449         int sequence = 1;
    450         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
    451             for (int iteration = 0; iteration < 9; ++iteration) {
    452                 name = prefix + Constants.FILENAME_SEQUENCE_SEPARATOR + sequence + suffix;
    453                 if (isFilenameAvailableLocked(parents, name)) {
    454                     return name;
    455                 }
    456                 sequence += sRandom.nextInt(magnitude) + 1;
    457             }
    458         }
    459 
    460         throw new IOException("Failed to generate an available filename");
    461     }
    462 
    463     static boolean isFilenameValid(Context context, File file) {
    464         return isFilenameValid(context, file, true);
    465     }
    466 
    467     static boolean isFilenameValidInExternal(Context context, File file) {
    468         return isFilenameValid(context, file, false);
    469     }
    470 
    471     /**
    472      * Test if given file exists in one of the package-specific external storage
    473      * directories that are always writable to apps, regardless of storage
    474      * permission.
    475      */
    476     static boolean isFilenameValidInExternalPackage(Context context, File file,
    477             String packageName) {
    478         try {
    479             if (containsCanonical(buildExternalStorageAppFilesDirs(packageName), file) ||
    480                     containsCanonical(buildExternalStorageAppObbDirs(packageName), file) ||
    481                     containsCanonical(buildExternalStorageAppCacheDirs(packageName), file) ||
    482                     containsCanonical(buildExternalStorageAppMediaDirs(packageName), file)) {
    483                 return true;
    484             }
    485         } catch (IOException e) {
    486             Log.w(TAG, "Failed to resolve canonical path: " + e);
    487             return false;
    488         }
    489 
    490         Log.w(TAG, "Path appears to be invalid: " + file);
    491         return false;
    492     }
    493 
    494     /**
    495      * Checks whether the filename looks legitimate for security purposes. This
    496      * prevents us from opening files that aren't actually downloads.
    497      */
    498     static boolean isFilenameValid(Context context, File file, boolean allowInternal) {
    499         try {
    500             if (allowInternal) {
    501                 if (containsCanonical(context.getFilesDir(), file)
    502                         || containsCanonical(context.getCacheDir(), file)
    503                         || containsCanonical(Environment.getDownloadCacheDirectory(), file)) {
    504                     return true;
    505                 }
    506             }
    507 
    508             final StorageVolume[] volumes = StorageManager.getVolumeList(UserHandle.myUserId(),
    509                     StorageManager.FLAG_FOR_WRITE);
    510             for (StorageVolume volume : volumes) {
    511                 if (containsCanonical(volume.getPathFile(), file)) {
    512                     return true;
    513                 }
    514             }
    515         } catch (IOException e) {
    516             Log.w(TAG, "Failed to resolve canonical path: " + e);
    517             return false;
    518         }
    519 
    520         Log.w(TAG, "Path appears to be invalid: " + file);
    521         return false;
    522     }
    523 
    524     private static boolean containsCanonical(File dir, File file) throws IOException {
    525         return FileUtils.contains(dir.getCanonicalFile(), file);
    526     }
    527 
    528     private static boolean containsCanonical(File[] dirs, File file) throws IOException {
    529         for (File dir : dirs) {
    530             if (containsCanonical(dir, file)) {
    531                 return true;
    532             }
    533         }
    534         return false;
    535     }
    536 
    537     public static File getRunningDestinationDirectory(Context context, int destination)
    538             throws IOException {
    539         return getDestinationDirectory(context, destination, true);
    540     }
    541 
    542     public static File getSuccessDestinationDirectory(Context context, int destination)
    543             throws IOException {
    544         return getDestinationDirectory(context, destination, false);
    545     }
    546 
    547     private static File getDestinationDirectory(Context context, int destination, boolean running)
    548             throws IOException {
    549         switch (destination) {
    550             case Downloads.Impl.DESTINATION_CACHE_PARTITION:
    551             case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE:
    552             case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING:
    553                 if (running) {
    554                     return context.getFilesDir();
    555                 } else {
    556                     return context.getCacheDir();
    557                 }
    558 
    559             case Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION:
    560                 if (running) {
    561                     return new File(Environment.getDownloadCacheDirectory(),
    562                             Constants.DIRECTORY_CACHE_RUNNING);
    563                 } else {
    564                     return Environment.getDownloadCacheDirectory();
    565                 }
    566 
    567             case Downloads.Impl.DESTINATION_EXTERNAL:
    568                 final File target = new File(
    569                         Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS);
    570                 if (!target.isDirectory() && target.mkdirs()) {
    571                     throw new IOException("unable to create external downloads directory");
    572                 }
    573                 return target;
    574 
    575             default:
    576                 throw new IllegalStateException("unexpected destination: " + destination);
    577         }
    578     }
    579 
    580     /**
    581      * Checks whether this looks like a legitimate selection parameter
    582      */
    583     public static void validateSelection(String selection, Set<String> allowedColumns) {
    584         try {
    585             if (selection == null || selection.isEmpty()) {
    586                 return;
    587             }
    588             Lexer lexer = new Lexer(selection, allowedColumns);
    589             parseExpression(lexer);
    590             if (lexer.currentToken() != Lexer.TOKEN_END) {
    591                 throw new IllegalArgumentException("syntax error");
    592             }
    593         } catch (RuntimeException ex) {
    594             if (Constants.LOGV) {
    595                 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex);
    596             } else if (false) {
    597                 Log.d(Constants.TAG, "invalid selection triggered " + ex);
    598             }
    599             throw ex;
    600         }
    601 
    602     }
    603 
    604     // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] *
    605     //             | statement [AND_OR expression]*
    606     private static void parseExpression(Lexer lexer) {
    607         for (;;) {
    608             // ( expression )
    609             if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) {
    610                 lexer.advance();
    611                 parseExpression(lexer);
    612                 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) {
    613                     throw new IllegalArgumentException("syntax error, unmatched parenthese");
    614                 }
    615                 lexer.advance();
    616             } else {
    617                 // statement
    618                 parseStatement(lexer);
    619             }
    620             if (lexer.currentToken() != Lexer.TOKEN_AND_OR) {
    621                 break;
    622             }
    623             lexer.advance();
    624         }
    625     }
    626 
    627     // statement <- COLUMN COMPARE VALUE
    628     //            | COLUMN IS NULL
    629     private static void parseStatement(Lexer lexer) {
    630         // both possibilities start with COLUMN
    631         if (lexer.currentToken() != Lexer.TOKEN_COLUMN) {
    632             throw new IllegalArgumentException("syntax error, expected column name");
    633         }
    634         lexer.advance();
    635 
    636         // statement <- COLUMN COMPARE VALUE
    637         if (lexer.currentToken() == Lexer.TOKEN_COMPARE) {
    638             lexer.advance();
    639             if (lexer.currentToken() != Lexer.TOKEN_VALUE) {
    640                 throw new IllegalArgumentException("syntax error, expected quoted string");
    641             }
    642             lexer.advance();
    643             return;
    644         }
    645 
    646         // statement <- COLUMN IS NULL
    647         if (lexer.currentToken() == Lexer.TOKEN_IS) {
    648             lexer.advance();
    649             if (lexer.currentToken() != Lexer.TOKEN_NULL) {
    650                 throw new IllegalArgumentException("syntax error, expected NULL");
    651             }
    652             lexer.advance();
    653             return;
    654         }
    655 
    656         // didn't get anything good after COLUMN
    657         throw new IllegalArgumentException("syntax error after column name");
    658     }
    659 
    660     /**
    661      * A simple lexer that recognizes the words of our restricted subset of SQL where clauses
    662      */
    663     private static class Lexer {
    664         public static final int TOKEN_START = 0;
    665         public static final int TOKEN_OPEN_PAREN = 1;
    666         public static final int TOKEN_CLOSE_PAREN = 2;
    667         public static final int TOKEN_AND_OR = 3;
    668         public static final int TOKEN_COLUMN = 4;
    669         public static final int TOKEN_COMPARE = 5;
    670         public static final int TOKEN_VALUE = 6;
    671         public static final int TOKEN_IS = 7;
    672         public static final int TOKEN_NULL = 8;
    673         public static final int TOKEN_END = 9;
    674 
    675         private final String mSelection;
    676         private final Set<String> mAllowedColumns;
    677         private int mOffset = 0;
    678         private int mCurrentToken = TOKEN_START;
    679         private final char[] mChars;
    680 
    681         public Lexer(String selection, Set<String> allowedColumns) {
    682             mSelection = selection;
    683             mAllowedColumns = allowedColumns;
    684             mChars = new char[mSelection.length()];
    685             mSelection.getChars(0, mChars.length, mChars, 0);
    686             advance();
    687         }
    688 
    689         public int currentToken() {
    690             return mCurrentToken;
    691         }
    692 
    693         public void advance() {
    694             char[] chars = mChars;
    695 
    696             // consume whitespace
    697             while (mOffset < chars.length && chars[mOffset] == ' ') {
    698                 ++mOffset;
    699             }
    700 
    701             // end of input
    702             if (mOffset == chars.length) {
    703                 mCurrentToken = TOKEN_END;
    704                 return;
    705             }
    706 
    707             // "("
    708             if (chars[mOffset] == '(') {
    709                 ++mOffset;
    710                 mCurrentToken = TOKEN_OPEN_PAREN;
    711                 return;
    712             }
    713 
    714             // ")"
    715             if (chars[mOffset] == ')') {
    716                 ++mOffset;
    717                 mCurrentToken = TOKEN_CLOSE_PAREN;
    718                 return;
    719             }
    720 
    721             // "?"
    722             if (chars[mOffset] == '?') {
    723                 ++mOffset;
    724                 mCurrentToken = TOKEN_VALUE;
    725                 return;
    726             }
    727 
    728             // "=" and "=="
    729             if (chars[mOffset] == '=') {
    730                 ++mOffset;
    731                 mCurrentToken = TOKEN_COMPARE;
    732                 if (mOffset < chars.length && chars[mOffset] == '=') {
    733                     ++mOffset;
    734                 }
    735                 return;
    736             }
    737 
    738             // ">" and ">="
    739             if (chars[mOffset] == '>') {
    740                 ++mOffset;
    741                 mCurrentToken = TOKEN_COMPARE;
    742                 if (mOffset < chars.length && chars[mOffset] == '=') {
    743                     ++mOffset;
    744                 }
    745                 return;
    746             }
    747 
    748             // "<", "<=" and "<>"
    749             if (chars[mOffset] == '<') {
    750                 ++mOffset;
    751                 mCurrentToken = TOKEN_COMPARE;
    752                 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) {
    753                     ++mOffset;
    754                 }
    755                 return;
    756             }
    757 
    758             // "!="
    759             if (chars[mOffset] == '!') {
    760                 ++mOffset;
    761                 mCurrentToken = TOKEN_COMPARE;
    762                 if (mOffset < chars.length && chars[mOffset] == '=') {
    763                     ++mOffset;
    764                     return;
    765                 }
    766                 throw new IllegalArgumentException("Unexpected character after !");
    767             }
    768 
    769             // columns and keywords
    770             // first look for anything that looks like an identifier or a keyword
    771             //     and then recognize the individual words.
    772             // no attempt is made at discarding sequences of underscores with no alphanumeric
    773             //     characters, even though it's not clear that they'd be legal column names.
    774             if (isIdentifierStart(chars[mOffset])) {
    775                 int startOffset = mOffset;
    776                 ++mOffset;
    777                 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) {
    778                     ++mOffset;
    779                 }
    780                 String word = mSelection.substring(startOffset, mOffset);
    781                 if (mOffset - startOffset <= 4) {
    782                     if (word.equals("IS")) {
    783                         mCurrentToken = TOKEN_IS;
    784                         return;
    785                     }
    786                     if (word.equals("OR") || word.equals("AND")) {
    787                         mCurrentToken = TOKEN_AND_OR;
    788                         return;
    789                     }
    790                     if (word.equals("NULL")) {
    791                         mCurrentToken = TOKEN_NULL;
    792                         return;
    793                     }
    794                 }
    795                 if (mAllowedColumns.contains(word)) {
    796                     mCurrentToken = TOKEN_COLUMN;
    797                     return;
    798                 }
    799                 throw new IllegalArgumentException("unrecognized column or keyword");
    800             }
    801 
    802             // quoted strings
    803             if (chars[mOffset] == '\'') {
    804                 ++mOffset;
    805                 while (mOffset < chars.length) {
    806                     if (chars[mOffset] == '\'') {
    807                         if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') {
    808                             ++mOffset;
    809                         } else {
    810                             break;
    811                         }
    812                     }
    813                     ++mOffset;
    814                 }
    815                 if (mOffset == chars.length) {
    816                     throw new IllegalArgumentException("unterminated string");
    817                 }
    818                 ++mOffset;
    819                 mCurrentToken = TOKEN_VALUE;
    820                 return;
    821             }
    822 
    823             // anything we don't recognize
    824             throw new IllegalArgumentException("illegal character: " + chars[mOffset]);
    825         }
    826 
    827         private static final boolean isIdentifierStart(char c) {
    828             return c == '_' ||
    829                     (c >= 'A' && c <= 'Z') ||
    830                     (c >= 'a' && c <= 'z');
    831         }
    832 
    833         private static final boolean isIdentifierChar(char c) {
    834             return c == '_' ||
    835                     (c >= 'A' && c <= 'Z') ||
    836                     (c >= 'a' && c <= 'z') ||
    837                     (c >= '0' && c <= '9');
    838         }
    839     }
    840 }
    841