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