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