Helpers.java revision 176a74426f750dc56e7d200a4cdc3b6ed75fe6cd
1/* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.providers.downloads; 18 19import android.content.ContentUris; 20import android.content.Context; 21import android.content.Intent; 22import android.content.pm.PackageManager; 23import android.content.pm.ResolveInfo; 24import android.database.Cursor; 25import android.drm.mobile1.DrmRawContent; 26import android.net.Uri; 27import android.os.Environment; 28import android.os.StatFs; 29import android.os.SystemClock; 30import android.provider.Downloads; 31import android.util.Config; 32import android.util.Log; 33import android.webkit.MimeTypeMap; 34 35import java.io.File; 36import java.io.FileNotFoundException; 37import java.io.FileOutputStream; 38import java.util.Random; 39import java.util.Set; 40import java.util.regex.Matcher; 41import java.util.regex.Pattern; 42 43/** 44 * Some helper functions for the download manager 45 */ 46public class Helpers { 47 48 public static Random sRandom = new Random(SystemClock.uptimeMillis()); 49 50 /** Regex used to parse content-disposition headers */ 51 private static final Pattern CONTENT_DISPOSITION_PATTERN = 52 Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); 53 54 private Helpers() { 55 } 56 57 /* 58 * Parse the Content-Disposition HTTP Header. The format of the header 59 * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html 60 * This header provides a filename for content that is going to be 61 * downloaded to the file system. We only support the attachment type. 62 */ 63 private static String parseContentDisposition(String contentDisposition) { 64 try { 65 Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition); 66 if (m.find()) { 67 return m.group(1); 68 } 69 } catch (IllegalStateException ex) { 70 // This function is defined as returning null when it can't parse the header 71 } 72 return null; 73 } 74 75 /** 76 * Exception thrown from methods called by generateSaveFile() for any fatal error. 77 */ 78 private static class GenerateSaveFileError extends Exception { 79 int mStatus; 80 81 public GenerateSaveFileError(int status) { 82 mStatus = status; 83 } 84 } 85 86 /** 87 * Creates a filename (where the file should be saved) from a uri. 88 */ 89 public static DownloadFileInfo generateSaveFile( 90 Context context, 91 String url, 92 String hint, 93 String contentDisposition, 94 String contentLocation, 95 String mimeType, 96 int destination, 97 long contentLength, 98 boolean isPublicApi) throws FileNotFoundException { 99 100 if (!canHandleDownload(context, mimeType, destination, isPublicApi)) { 101 return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_NOT_ACCEPTABLE); 102 } 103 104 String fullFilename; 105 try { 106 if (destination == Downloads.Impl.DESTINATION_FILE_URI) { 107 fullFilename = getPathForFileUri(hint); 108 } else { 109 fullFilename = chooseFullPath(context, url, hint, contentDisposition, 110 contentLocation, mimeType, destination, 111 contentLength); 112 } 113 } catch (GenerateSaveFileError exc) { 114 return new DownloadFileInfo(null, null, exc.mStatus); 115 } 116 117 return new DownloadFileInfo(fullFilename, new FileOutputStream(fullFilename), 0); 118 } 119 120 private static String getPathForFileUri(String hint) throws GenerateSaveFileError { 121 String path = Uri.parse(hint).getSchemeSpecificPart(); 122 if (new File(path).exists()) { 123 Log.d(Constants.TAG, "File already exists: " + path); 124 throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); 125 } 126 127 return path; 128 } 129 130 private static String chooseFullPath(Context context, String url, String hint, 131 String contentDisposition, String contentLocation, 132 String mimeType, int destination, long contentLength) 133 throws GenerateSaveFileError { 134 File base = locateDestinationDirectory(context, mimeType, destination, contentLength); 135 String filename = chooseFilename(url, hint, contentDisposition, contentLocation, 136 destination); 137 138 // Split filename between base and extension 139 // Add an extension if filename does not have one 140 String extension = null; 141 int dotIndex = filename.indexOf('.'); 142 if (dotIndex < 0) { 143 extension = chooseExtensionFromMimeType(mimeType, true); 144 } else { 145 extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex); 146 filename = filename.substring(0, dotIndex); 147 } 148 149 boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension); 150 151 filename = base.getPath() + File.separator + filename; 152 153 if (Constants.LOGVV) { 154 Log.v(Constants.TAG, "target file: " + filename + extension); 155 } 156 157 return chooseUniqueFilename(destination, filename, extension, recoveryDir); 158 } 159 160 private static boolean canHandleDownload(Context context, String mimeType, int destination, 161 boolean isPublicApi) { 162 if (isPublicApi) { 163 return true; 164 } 165 166 if (destination == Downloads.Impl.DESTINATION_EXTERNAL 167 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) { 168 if (mimeType == null) { 169 if (Config.LOGD) { 170 Log.d(Constants.TAG, "external download with no mime type not allowed"); 171 } 172 return false; 173 } 174 if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { 175 // Check to see if we are allowed to download this file. Only files 176 // that can be handled by the platform can be downloaded. 177 // special case DRM files, which we should always allow downloading. 178 Intent intent = new Intent(Intent.ACTION_VIEW); 179 180 // We can provide data as either content: or file: URIs, 181 // so allow both. (I think it would be nice if we just did 182 // everything as content: URIs) 183 // Actually, right now the download manager's UId restrictions 184 // prevent use from using content: so it's got to be file: or 185 // nothing 186 187 PackageManager pm = context.getPackageManager(); 188 intent.setDataAndType(Uri.fromParts("file", "", null), mimeType); 189 ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); 190 //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list); 191 192 if (ri == null) { 193 if (Config.LOGD) { 194 Log.d(Constants.TAG, "no handler found for type " + mimeType); 195 } 196 return false; 197 } 198 } 199 } 200 return true; 201 } 202 203 private static File locateDestinationDirectory(Context context, String mimeType, 204 int destination, long contentLength) 205 throws GenerateSaveFileError { 206 File base = null; 207 StatFs stat = null; 208 // DRM messages should be temporarily stored internally and then passed to 209 // the DRM content provider 210 if (destination == Downloads.Impl.DESTINATION_CACHE_PARTITION 211 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE 212 || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING 213 || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { 214 // Saving to internal storage. 215 base = Environment.getDownloadCacheDirectory(); 216 stat = new StatFs(base.getPath()); 217 218 /* 219 * Check whether there's enough space on the target filesystem to save the file. 220 * Put a bit of margin (in case creating the file grows the system by a few blocks). 221 */ 222 int blockSize = stat.getBlockSize(); 223 long bytesAvailable = blockSize * ((long) stat.getAvailableBlocks() - 4); 224 while (bytesAvailable < contentLength) { 225 // Insufficient space; try discarding purgeable files. 226 if (!discardPurgeableFiles(context, contentLength - bytesAvailable)) { 227 // No files to purge, give up. 228 if (Config.LOGD) { 229 Log.d(Constants.TAG, 230 "download aborted - not enough free space in internal storage"); 231 } 232 throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); 233 } else { 234 // Recalculate available space and try again. 235 stat.restat(base.getPath()); 236 bytesAvailable = blockSize * ((long) stat.getAvailableBlocks() - 4); 237 } 238 } 239 } else if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { 240 // Saving to external storage (SD card). 241 String root = Environment.getExternalStorageDirectory().getPath(); 242 stat = new StatFs(root); 243 244 /* 245 * Check whether there's enough space on the target filesystem to save the file. 246 * Put a bit of margin (in case creating the file grows the system by a few blocks). 247 */ 248 if (stat.getBlockSize() * ((long) stat.getAvailableBlocks() - 4) < contentLength) { 249 // Insufficient space. 250 if (Config.LOGD) { 251 Log.d(Constants.TAG, "download aborted - not enough free space"); 252 } 253 throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); 254 } 255 256 base = new File(root + Constants.DEFAULT_DL_SUBDIR); 257 if (!base.isDirectory() && !base.mkdir()) { 258 // Can't create download directory, e.g. because a file called "download" 259 // already exists at the root level, or the SD card filesystem is read-only. 260 if (Config.LOGD) { 261 Log.d(Constants.TAG, "download aborted - can't create base directory " 262 + base.getPath()); 263 } 264 throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); 265 } 266 } else { 267 // No SD card found. 268 if (Config.LOGD) { 269 Log.d(Constants.TAG, "download aborted - no external storage"); 270 } 271 throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR); 272 } 273 274 return base; 275 } 276 277 private static String chooseFilename(String url, String hint, String contentDisposition, 278 String contentLocation, int destination) { 279 String filename = null; 280 281 // First, try to use the hint from the application, if there's one 282 if (filename == null && hint != null && !hint.endsWith("/")) { 283 if (Constants.LOGVV) { 284 Log.v(Constants.TAG, "getting filename from hint"); 285 } 286 int index = hint.lastIndexOf('/') + 1; 287 if (index > 0) { 288 filename = hint.substring(index); 289 } else { 290 filename = hint; 291 } 292 } 293 294 // If we couldn't do anything with the hint, move toward the content disposition 295 if (filename == null && contentDisposition != null) { 296 filename = parseContentDisposition(contentDisposition); 297 if (filename != null) { 298 if (Constants.LOGVV) { 299 Log.v(Constants.TAG, "getting filename from content-disposition"); 300 } 301 int index = filename.lastIndexOf('/') + 1; 302 if (index > 0) { 303 filename = filename.substring(index); 304 } 305 } 306 } 307 308 // If we still have nothing at this point, try the content location 309 if (filename == null && contentLocation != null) { 310 String decodedContentLocation = Uri.decode(contentLocation); 311 if (decodedContentLocation != null 312 && !decodedContentLocation.endsWith("/") 313 && decodedContentLocation.indexOf('?') < 0) { 314 if (Constants.LOGVV) { 315 Log.v(Constants.TAG, "getting filename from content-location"); 316 } 317 int index = decodedContentLocation.lastIndexOf('/') + 1; 318 if (index > 0) { 319 filename = decodedContentLocation.substring(index); 320 } else { 321 filename = decodedContentLocation; 322 } 323 } 324 } 325 326 // If all the other http-related approaches failed, use the plain uri 327 if (filename == null) { 328 String decodedUrl = Uri.decode(url); 329 if (decodedUrl != null 330 && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) { 331 int index = decodedUrl.lastIndexOf('/') + 1; 332 if (index > 0) { 333 if (Constants.LOGVV) { 334 Log.v(Constants.TAG, "getting filename from uri"); 335 } 336 filename = decodedUrl.substring(index); 337 } 338 } 339 } 340 341 // Finally, if couldn't get filename from URI, get a generic filename 342 if (filename == null) { 343 if (Constants.LOGVV) { 344 Log.v(Constants.TAG, "using default filename"); 345 } 346 filename = Constants.DEFAULT_DL_FILENAME; 347 } 348 349 // The VFAT file system is assumed as target for downloads. 350 // Replace invalid characters according to the specifications of VFAT. 351 filename = replaceInvalidVfatCharacters(filename); 352 353 return filename; 354 } 355 356 private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) { 357 String extension = null; 358 if (mimeType != null) { 359 extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); 360 if (extension != null) { 361 if (Constants.LOGVV) { 362 Log.v(Constants.TAG, "adding extension from type"); 363 } 364 extension = "." + extension; 365 } else { 366 if (Constants.LOGVV) { 367 Log.v(Constants.TAG, "couldn't find extension for " + mimeType); 368 } 369 } 370 } 371 if (extension == null) { 372 if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) { 373 if (mimeType.equalsIgnoreCase("text/html")) { 374 if (Constants.LOGVV) { 375 Log.v(Constants.TAG, "adding default html extension"); 376 } 377 extension = Constants.DEFAULT_DL_HTML_EXTENSION; 378 } else if (useDefaults) { 379 if (Constants.LOGVV) { 380 Log.v(Constants.TAG, "adding default text extension"); 381 } 382 extension = Constants.DEFAULT_DL_TEXT_EXTENSION; 383 } 384 } else if (useDefaults) { 385 if (Constants.LOGVV) { 386 Log.v(Constants.TAG, "adding default binary extension"); 387 } 388 extension = Constants.DEFAULT_DL_BINARY_EXTENSION; 389 } 390 } 391 return extension; 392 } 393 394 private static String chooseExtensionFromFilename(String mimeType, int destination, 395 String filename, int dotIndex) { 396 String extension = null; 397 if (mimeType != null) { 398 // Compare the last segment of the extension against the mime type. 399 // If there's a mismatch, discard the entire extension. 400 int lastDotIndex = filename.lastIndexOf('.'); 401 String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension( 402 filename.substring(lastDotIndex + 1)); 403 if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) { 404 extension = chooseExtensionFromMimeType(mimeType, false); 405 if (extension != null) { 406 if (Constants.LOGVV) { 407 Log.v(Constants.TAG, "substituting extension from type"); 408 } 409 } else { 410 if (Constants.LOGVV) { 411 Log.v(Constants.TAG, "couldn't find extension for " + mimeType); 412 } 413 } 414 } 415 } 416 if (extension == null) { 417 if (Constants.LOGVV) { 418 Log.v(Constants.TAG, "keeping extension"); 419 } 420 extension = filename.substring(dotIndex); 421 } 422 return extension; 423 } 424 425 private static String chooseUniqueFilename(int destination, String filename, 426 String extension, boolean recoveryDir) throws GenerateSaveFileError { 427 String fullFilename = filename + extension; 428 if (!new File(fullFilename).exists() 429 && (!recoveryDir || 430 (destination != Downloads.Impl.DESTINATION_CACHE_PARTITION && 431 destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE && 432 destination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING))) { 433 return fullFilename; 434 } 435 filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR; 436 /* 437 * This number is used to generate partially randomized filenames to avoid 438 * collisions. 439 * It starts at 1. 440 * The next 9 iterations increment it by 1 at a time (up to 10). 441 * The next 9 iterations increment it by 1 to 10 (random) at a time. 442 * The next 9 iterations increment it by 1 to 100 (random) at a time. 443 * ... Up to the point where it increases by 100000000 at a time. 444 * (the maximum value that can be reached is 1000000000) 445 * As soon as a number is reached that generates a filename that doesn't exist, 446 * that filename is used. 447 * If the filename coming in is [base].[ext], the generated filenames are 448 * [base]-[sequence].[ext]. 449 */ 450 int sequence = 1; 451 for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) { 452 for (int iteration = 0; iteration < 9; ++iteration) { 453 fullFilename = filename + sequence + extension; 454 if (!new File(fullFilename).exists()) { 455 return fullFilename; 456 } 457 if (Constants.LOGVV) { 458 Log.v(Constants.TAG, "file with sequence number " + sequence + " exists"); 459 } 460 sequence += sRandom.nextInt(magnitude) + 1; 461 } 462 } 463 throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); 464 } 465 466 /** 467 * Deletes purgeable files from the cache partition. This also deletes 468 * the matching database entries. Files are deleted in LRU order until 469 * the total byte size is greater than targetBytes. 470 */ 471 public static final boolean discardPurgeableFiles(Context context, long targetBytes) { 472 Cursor cursor = context.getContentResolver().query( 473 Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, 474 null, 475 "( " + 476 Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " + 477 Downloads.Impl.COLUMN_DESTINATION + 478 " = '" + Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE + "' )", 479 null, 480 Downloads.Impl.COLUMN_LAST_MODIFICATION); 481 if (cursor == null) { 482 return false; 483 } 484 long totalFreed = 0; 485 try { 486 cursor.moveToFirst(); 487 while (!cursor.isAfterLast() && totalFreed < targetBytes) { 488 File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA))); 489 if (Constants.LOGVV) { 490 Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " + 491 file.length() + " bytes"); 492 } 493 totalFreed += file.length(); 494 file.delete(); 495 long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID)); 496 context.getContentResolver().delete( 497 ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id), 498 null, null); 499 cursor.moveToNext(); 500 } 501 } finally { 502 cursor.close(); 503 } 504 if (Constants.LOGV) { 505 if (totalFreed > 0) { 506 Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " + 507 targetBytes + " requested"); 508 } 509 } 510 return totalFreed > 0; 511 } 512 513 /** 514 * Returns whether the network is available 515 */ 516 public static boolean isNetworkAvailable(SystemFacade system) { 517 return system.getActiveNetworkType() != null; 518 } 519 520 /** 521 * Checks whether the filename looks legitimate 522 */ 523 public static boolean isFilenameValid(String filename) { 524 filename = filename.replaceFirst("/+", "/"); // normalize leading slashes 525 return filename.startsWith(Environment.getDownloadCacheDirectory().toString()) 526 || filename.startsWith(Environment.getExternalStorageDirectory().toString()); 527 } 528 529 /** 530 * Checks whether this looks like a legitimate selection parameter 531 */ 532 public static void validateSelection(String selection, Set<String> allowedColumns) { 533 try { 534 if (selection == null || selection.isEmpty()) { 535 return; 536 } 537 Lexer lexer = new Lexer(selection, allowedColumns); 538 parseExpression(lexer); 539 if (lexer.currentToken() != Lexer.TOKEN_END) { 540 throw new IllegalArgumentException("syntax error"); 541 } 542 } catch (RuntimeException ex) { 543 if (Constants.LOGV) { 544 Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex); 545 } else if (Config.LOGD) { 546 Log.d(Constants.TAG, "invalid selection triggered " + ex); 547 } 548 throw ex; 549 } 550 551 } 552 553 // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] * 554 // | statement [AND_OR expression]* 555 private static void parseExpression(Lexer lexer) { 556 for (;;) { 557 // ( expression ) 558 if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) { 559 lexer.advance(); 560 parseExpression(lexer); 561 if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) { 562 throw new IllegalArgumentException("syntax error, unmatched parenthese"); 563 } 564 lexer.advance(); 565 } else { 566 // statement 567 parseStatement(lexer); 568 } 569 if (lexer.currentToken() != Lexer.TOKEN_AND_OR) { 570 break; 571 } 572 lexer.advance(); 573 } 574 } 575 576 // statement <- COLUMN COMPARE VALUE 577 // | COLUMN IS NULL 578 private static void parseStatement(Lexer lexer) { 579 // both possibilities start with COLUMN 580 if (lexer.currentToken() != Lexer.TOKEN_COLUMN) { 581 throw new IllegalArgumentException("syntax error, expected column name"); 582 } 583 lexer.advance(); 584 585 // statement <- COLUMN COMPARE VALUE 586 if (lexer.currentToken() == Lexer.TOKEN_COMPARE) { 587 lexer.advance(); 588 if (lexer.currentToken() != Lexer.TOKEN_VALUE) { 589 throw new IllegalArgumentException("syntax error, expected quoted string"); 590 } 591 lexer.advance(); 592 return; 593 } 594 595 // statement <- COLUMN IS NULL 596 if (lexer.currentToken() == Lexer.TOKEN_IS) { 597 lexer.advance(); 598 if (lexer.currentToken() != Lexer.TOKEN_NULL) { 599 throw new IllegalArgumentException("syntax error, expected NULL"); 600 } 601 lexer.advance(); 602 return; 603 } 604 605 // didn't get anything good after COLUMN 606 throw new IllegalArgumentException("syntax error after column name"); 607 } 608 609 /** 610 * A simple lexer that recognizes the words of our restricted subset of SQL where clauses 611 */ 612 private static class Lexer { 613 public static final int TOKEN_START = 0; 614 public static final int TOKEN_OPEN_PAREN = 1; 615 public static final int TOKEN_CLOSE_PAREN = 2; 616 public static final int TOKEN_AND_OR = 3; 617 public static final int TOKEN_COLUMN = 4; 618 public static final int TOKEN_COMPARE = 5; 619 public static final int TOKEN_VALUE = 6; 620 public static final int TOKEN_IS = 7; 621 public static final int TOKEN_NULL = 8; 622 public static final int TOKEN_END = 9; 623 624 private final String mSelection; 625 private final Set<String> mAllowedColumns; 626 private int mOffset = 0; 627 private int mCurrentToken = TOKEN_START; 628 private final char[] mChars; 629 630 public Lexer(String selection, Set<String> allowedColumns) { 631 mSelection = selection; 632 mAllowedColumns = allowedColumns; 633 mChars = new char[mSelection.length()]; 634 mSelection.getChars(0, mChars.length, mChars, 0); 635 advance(); 636 } 637 638 public int currentToken() { 639 return mCurrentToken; 640 } 641 642 public void advance() { 643 char[] chars = mChars; 644 645 // consume whitespace 646 while (mOffset < chars.length && chars[mOffset] == ' ') { 647 ++mOffset; 648 } 649 650 // end of input 651 if (mOffset == chars.length) { 652 mCurrentToken = TOKEN_END; 653 return; 654 } 655 656 // "(" 657 if (chars[mOffset] == '(') { 658 ++mOffset; 659 mCurrentToken = TOKEN_OPEN_PAREN; 660 return; 661 } 662 663 // ")" 664 if (chars[mOffset] == ')') { 665 ++mOffset; 666 mCurrentToken = TOKEN_CLOSE_PAREN; 667 return; 668 } 669 670 // "?" 671 if (chars[mOffset] == '?') { 672 ++mOffset; 673 mCurrentToken = TOKEN_VALUE; 674 return; 675 } 676 677 // "=" and "==" 678 if (chars[mOffset] == '=') { 679 ++mOffset; 680 mCurrentToken = TOKEN_COMPARE; 681 if (mOffset < chars.length && chars[mOffset] == '=') { 682 ++mOffset; 683 } 684 return; 685 } 686 687 // ">" and ">=" 688 if (chars[mOffset] == '>') { 689 ++mOffset; 690 mCurrentToken = TOKEN_COMPARE; 691 if (mOffset < chars.length && chars[mOffset] == '=') { 692 ++mOffset; 693 } 694 return; 695 } 696 697 // "<", "<=" and "<>" 698 if (chars[mOffset] == '<') { 699 ++mOffset; 700 mCurrentToken = TOKEN_COMPARE; 701 if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) { 702 ++mOffset; 703 } 704 return; 705 } 706 707 // "!=" 708 if (chars[mOffset] == '!') { 709 ++mOffset; 710 mCurrentToken = TOKEN_COMPARE; 711 if (mOffset < chars.length && chars[mOffset] == '=') { 712 ++mOffset; 713 return; 714 } 715 throw new IllegalArgumentException("Unexpected character after !"); 716 } 717 718 // columns and keywords 719 // first look for anything that looks like an identifier or a keyword 720 // and then recognize the individual words. 721 // no attempt is made at discarding sequences of underscores with no alphanumeric 722 // characters, even though it's not clear that they'd be legal column names. 723 if (isIdentifierStart(chars[mOffset])) { 724 int startOffset = mOffset; 725 ++mOffset; 726 while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) { 727 ++mOffset; 728 } 729 String word = mSelection.substring(startOffset, mOffset); 730 if (mOffset - startOffset <= 4) { 731 if (word.equals("IS")) { 732 mCurrentToken = TOKEN_IS; 733 return; 734 } 735 if (word.equals("OR") || word.equals("AND")) { 736 mCurrentToken = TOKEN_AND_OR; 737 return; 738 } 739 if (word.equals("NULL")) { 740 mCurrentToken = TOKEN_NULL; 741 return; 742 } 743 } 744 if (mAllowedColumns.contains(word)) { 745 mCurrentToken = TOKEN_COLUMN; 746 return; 747 } 748 throw new IllegalArgumentException("unrecognized column or keyword"); 749 } 750 751 // quoted strings 752 if (chars[mOffset] == '\'') { 753 ++mOffset; 754 while (mOffset < chars.length) { 755 if (chars[mOffset] == '\'') { 756 if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') { 757 ++mOffset; 758 } else { 759 break; 760 } 761 } 762 ++mOffset; 763 } 764 if (mOffset == chars.length) { 765 throw new IllegalArgumentException("unterminated string"); 766 } 767 ++mOffset; 768 mCurrentToken = TOKEN_VALUE; 769 return; 770 } 771 772 // anything we don't recognize 773 throw new IllegalArgumentException("illegal character: " + chars[mOffset]); 774 } 775 776 private static final boolean isIdentifierStart(char c) { 777 return c == '_' || 778 (c >= 'A' && c <= 'Z') || 779 (c >= 'a' && c <= 'z'); 780 } 781 782 private static final boolean isIdentifierChar(char c) { 783 return c == '_' || 784 (c >= 'A' && c <= 'Z') || 785 (c >= 'a' && c <= 'z') || 786 (c >= '0' && c <= '9'); 787 } 788 } 789 790 /** 791 * Replace invalid filename characters according to 792 * specifications of the VFAT. 793 * @note Package-private due to testing. 794 */ 795 private static String replaceInvalidVfatCharacters(String filename) { 796 final char START_CTRLCODE = 0x00; 797 final char END_CTRLCODE = 0x1f; 798 final char QUOTEDBL = 0x22; 799 final char ASTERISK = 0x2A; 800 final char SLASH = 0x2F; 801 final char COLON = 0x3A; 802 final char LESS = 0x3C; 803 final char GREATER = 0x3E; 804 final char QUESTION = 0x3F; 805 final char BACKSLASH = 0x5C; 806 final char BAR = 0x7C; 807 final char DEL = 0x7F; 808 final char UNDERSCORE = 0x5F; 809 810 StringBuffer sb = new StringBuffer(); 811 char ch; 812 boolean isRepetition = false; 813 for (int i = 0; i < filename.length(); i++) { 814 ch = filename.charAt(i); 815 if ((START_CTRLCODE <= ch && 816 ch <= END_CTRLCODE) || 817 ch == QUOTEDBL || 818 ch == ASTERISK || 819 ch == SLASH || 820 ch == COLON || 821 ch == LESS || 822 ch == GREATER || 823 ch == QUESTION || 824 ch == BACKSLASH || 825 ch == BAR || 826 ch == DEL){ 827 if (!isRepetition) { 828 sb.append(UNDERSCORE); 829 isRepetition = true; 830 } 831 } else { 832 sb.append(ch); 833 isRepetition = false; 834 } 835 } 836 return sb.toString(); 837 } 838} 839