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