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 
Helpers()74     private Helpers() {
75     }
76 
getAsyncHandler()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
setSystemFacade(SystemFacade systemFacade)88     public synchronized static void setSystemFacade(SystemFacade systemFacade) {
89         sSystemFacade = systemFacade;
90     }
91 
getSystemFacade(Context context)92     public synchronized static SystemFacade getSystemFacade(Context context) {
93         if (sSystemFacade == null) {
94             sSystemFacade = new RealSystemFacade(context);
95         }
96         return sSystemFacade;
97     }
98 
getDownloadNotifier(Context context)99     public synchronized static DownloadNotifier getDownloadNotifier(Context context) {
100         if (sNotifier == null) {
101             sNotifier = new DownloadNotifier(context);
102         }
103         return sNotifier;
104     }
105 
getString(Cursor cursor, String col)106     public static String getString(Cursor cursor, String col) {
107         return cursor.getString(cursor.getColumnIndexOrThrow(col));
108     }
109 
getInt(Cursor cursor, String col)110     public static int getInt(Cursor cursor, String col) {
111         return cursor.getInt(cursor.getColumnIndexOrThrow(col));
112     }
113 
scheduleJob(Context context, long downloadId)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      */
scheduleJob(Context context, DownloadInfo info)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      */
parseContentDisposition(String contentDisposition)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      */
generateSaveFile(Context context, String url, String hint, String contentDisposition, String contentLocation, String mimeType, int destination)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 
chooseExtensionFromMimeType(String mimeType, boolean useDefaults)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 
chooseExtensionFromFilename(String mimeType, int destination, String filename, int lastDotIndex)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 
isFilenameAvailableLocked(File[] parents, String name)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 
generateAvailableFilenameLocked( File[] parents, String prefix, String suffix)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 
isFilenameValid(Context context, File file)463     static boolean isFilenameValid(Context context, File file) {
464         return isFilenameValid(context, file, true);
465     }
466 
isFilenameValidInExternal(Context context, File file)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      */
isFilenameValidInExternalPackage(Context context, File file, String packageName)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      */
isFilenameValid(Context context, File file, boolean allowInternal)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 
containsCanonical(File dir, File file)524     private static boolean containsCanonical(File dir, File file) throws IOException {
525         return FileUtils.contains(dir.getCanonicalFile(), file);
526     }
527 
containsCanonical(File[] dirs, File file)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 
getRunningDestinationDirectory(Context context, int destination)537     public static File getRunningDestinationDirectory(Context context, int destination)
538             throws IOException {
539         return getDestinationDirectory(context, destination, true);
540     }
541 
getSuccessDestinationDirectory(Context context, int destination)542     public static File getSuccessDestinationDirectory(Context context, int destination)
543             throws IOException {
544         return getDestinationDirectory(context, destination, false);
545     }
546 
getDestinationDirectory(Context context, int destination, boolean running)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      */
validateSelection(String selection, Set<String> allowedColumns)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]*
parseExpression(Lexer lexer)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
parseStatement(Lexer lexer)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 
Lexer(String selection, Set<String> allowedColumns)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 
currentToken()689         public int currentToken() {
690             return mCurrentToken;
691         }
692 
advance()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: " + word);
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 
isIdentifierStart(char c)827         private static final boolean isIdentifierStart(char c) {
828             return c == '_' ||
829                     (c >= 'A' && c <= 'Z') ||
830                     (c >= 'a' && c <= 'z');
831         }
832 
isIdentifierChar(char c)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