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