ExpandableBinaryDictionary.java revision 2725cc17d7e843fe1107f1209ce07458cffbf58c
1/*
2 * Copyright (C) 2012 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.inputmethod.latin;
18
19import android.content.Context;
20import android.util.Log;
21
22import com.android.inputmethod.annotations.UsedForTesting;
23import com.android.inputmethod.keyboard.ProximityInfo;
24import com.android.inputmethod.latin.makedict.DictionaryHeader;
25import com.android.inputmethod.latin.makedict.FormatSpec;
26import com.android.inputmethod.latin.makedict.UnsupportedFormatException;
27import com.android.inputmethod.latin.makedict.WordProperty;
28import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
29import com.android.inputmethod.latin.utils.CombinedFormatUtils;
30import com.android.inputmethod.latin.utils.DistracterFilter;
31import com.android.inputmethod.latin.utils.ExecutorUtils;
32import com.android.inputmethod.latin.utils.FileUtils;
33import com.android.inputmethod.latin.utils.LanguageModelParam;
34
35import java.io.File;
36import java.util.ArrayList;
37import java.util.HashMap;
38import java.util.Locale;
39import java.util.Map;
40import java.util.concurrent.CountDownLatch;
41import java.util.concurrent.TimeUnit;
42import java.util.concurrent.atomic.AtomicBoolean;
43import java.util.concurrent.locks.Lock;
44import java.util.concurrent.locks.ReentrantReadWriteLock;
45
46/**
47 * Abstract base class for an expandable dictionary that can be created and updated dynamically
48 * during runtime. When updated it automatically generates a new binary dictionary to handle future
49 * queries in native code. This binary dictionary is written to internal storage.
50 */
51abstract public class ExpandableBinaryDictionary extends Dictionary {
52    private static final boolean DEBUG = false;
53
54    /** Used for Log actions from this class */
55    private static final String TAG = ExpandableBinaryDictionary.class.getSimpleName();
56
57    /** Whether to print debug output to log */
58    private static final boolean DBG_STRESS_TEST = false;
59
60    private static final int TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS = 100;
61
62    private static final int DEFAULT_MAX_UNIGRAM_COUNT = 10000;
63    private static final int DEFAULT_MAX_BIGRAM_COUNT = 10000;
64
65    /**
66     * The maximum length of a word in this dictionary.
67     */
68    protected static final int MAX_WORD_LENGTH = Constants.DICTIONARY_MAX_WORD_LENGTH;
69
70    private static final int DICTIONARY_FORMAT_VERSION = FormatSpec.VERSION4;
71
72    /** The application context. */
73    protected final Context mContext;
74
75    /**
76     * The binary dictionary generated dynamically from the fusion dictionary. This is used to
77     * answer unigram and bigram queries.
78     */
79    private BinaryDictionary mBinaryDictionary;
80
81    /**
82     * The name of this dictionary, used as a part of the filename for storing the binary
83     * dictionary.
84     */
85    private final String mDictName;
86
87    /** Dictionary locale */
88    private final Locale mLocale;
89
90    /** Dictionary file */
91    private final File mDictFile;
92
93    /** Indicates whether a task for reloading the dictionary has been scheduled. */
94    private final AtomicBoolean mIsReloading;
95
96    /** Indicates whether the current dictionary needs to be recreated. */
97    private boolean mNeedsToRecreate;
98
99    private final ReentrantReadWriteLock mLock;
100
101    private Map<String, String> mAdditionalAttributeMap = null;
102
103    /* A extension for a binary dictionary file. */
104    protected static final String DICT_FILE_EXTENSION = ".dict";
105
106    /**
107     * Abstract method for loading initial contents of a given dictionary.
108     */
109    protected abstract void loadInitialContentsLocked();
110
111    private boolean matchesExpectedBinaryDictFormatVersionForThisType(final int formatVersion) {
112        return formatVersion == FormatSpec.VERSION4;
113    }
114
115    private boolean needsToMigrateDictionary(final int formatVersion) {
116        // When we bump up the dictionary format version, the old version should be added to here
117        // for supporting migration. Note that native code has to support reading such formats.
118        return formatVersion == FormatSpec.VERSION4_ONLY_FOR_TESTING;
119    }
120
121    public boolean isValidDictionaryLocked() {
122        return mBinaryDictionary.isValidDictionary();
123    }
124
125    /**
126     * Creates a new expandable binary dictionary.
127     *
128     * @param context The application context of the parent.
129     * @param dictName The name of the dictionary. Multiple instances with the same
130     *        name is supported.
131     * @param locale the dictionary locale.
132     * @param dictType the dictionary type, as a human-readable string
133     * @param dictFile dictionary file path. if null, use default dictionary path based on
134     *        dictionary type.
135     */
136    public ExpandableBinaryDictionary(final Context context, final String dictName,
137            final Locale locale, final String dictType, final File dictFile) {
138        super(dictType);
139        mDictName = dictName;
140        mContext = context;
141        mLocale = locale;
142        mDictFile = getDictFile(context, dictName, dictFile);
143        mBinaryDictionary = null;
144        mIsReloading = new AtomicBoolean();
145        mNeedsToRecreate = false;
146        mLock = new ReentrantReadWriteLock();
147    }
148
149    public static File getDictFile(final Context context, final String dictName,
150            final File dictFile) {
151        return (dictFile != null) ? dictFile
152                : new File(context.getFilesDir(), dictName + DICT_FILE_EXTENSION);
153    }
154
155    public static String getDictName(final String name, final Locale locale,
156            final File dictFile) {
157        return dictFile != null ? dictFile.getName() : name + "." + locale.toString();
158    }
159
160    private void asyncExecuteTaskWithWriteLock(final Runnable task) {
161        asyncExecuteTaskWithLock(mLock.writeLock(), task);
162    }
163
164    private void asyncExecuteTaskWithLock(final Lock lock, final Runnable task) {
165        ExecutorUtils.getExecutor(mDictName).execute(new Runnable() {
166            @Override
167            public void run() {
168                lock.lock();
169                try {
170                    task.run();
171                } finally {
172                    lock.unlock();
173                }
174            }
175        });
176    }
177
178    /**
179     * Closes and cleans up the binary dictionary.
180     */
181    @Override
182    public void close() {
183        asyncExecuteTaskWithWriteLock(new Runnable() {
184            @Override
185            public void run() {
186                if (mBinaryDictionary != null) {
187                    mBinaryDictionary.close();
188                    mBinaryDictionary = null;
189                }
190            }
191        });
192    }
193
194    protected Map<String, String> getHeaderAttributeMap() {
195        HashMap<String, String> attributeMap = new HashMap<>();
196        if (mAdditionalAttributeMap != null) {
197            attributeMap.putAll(mAdditionalAttributeMap);
198        }
199        attributeMap.put(DictionaryHeader.DICTIONARY_ID_KEY, mDictName);
200        attributeMap.put(DictionaryHeader.DICTIONARY_LOCALE_KEY, mLocale.toString());
201        attributeMap.put(DictionaryHeader.DICTIONARY_VERSION_KEY,
202                String.valueOf(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())));
203        attributeMap.put(DictionaryHeader.MAX_UNIGRAM_COUNT_KEY,
204                String.valueOf(DEFAULT_MAX_UNIGRAM_COUNT));
205        attributeMap.put(DictionaryHeader.MAX_BIGRAM_COUNT_KEY,
206                String.valueOf(DEFAULT_MAX_BIGRAM_COUNT));
207        return attributeMap;
208    }
209
210    private void removeBinaryDictionary() {
211        asyncExecuteTaskWithWriteLock(new Runnable() {
212            @Override
213            public void run() {
214                removeBinaryDictionaryLocked();
215            }
216        });
217    }
218
219    private void removeBinaryDictionaryLocked() {
220        if (mBinaryDictionary != null) {
221            mBinaryDictionary.close();
222        }
223        if (mDictFile.exists() && !FileUtils.deleteRecursively(mDictFile)) {
224            Log.e(TAG, "Can't remove a file: " + mDictFile.getName());
225        }
226        mBinaryDictionary = null;
227    }
228
229    private void openBinaryDictionaryLocked() {
230        mBinaryDictionary = new BinaryDictionary(
231                mDictFile.getAbsolutePath(), 0 /* offset */, mDictFile.length(),
232                true /* useFullEditDistance */, mLocale, mDictType, true /* isUpdatable */);
233    }
234
235    private void createOnMemoryBinaryDictionaryLocked() {
236        mBinaryDictionary = new BinaryDictionary(
237                mDictFile.getAbsolutePath(), true /* useFullEditDistance */, mLocale, mDictType,
238                DICTIONARY_FORMAT_VERSION, getHeaderAttributeMap());
239    }
240
241    public void clear() {
242        asyncExecuteTaskWithWriteLock(new Runnable() {
243            @Override
244            public void run() {
245                removeBinaryDictionaryLocked();
246                createOnMemoryBinaryDictionaryLocked();
247            }
248        });
249    }
250
251    /**
252     * Check whether GC is needed and run GC if required.
253     */
254    protected void runGCIfRequired(final boolean mindsBlockByGC) {
255        asyncExecuteTaskWithWriteLock(new Runnable() {
256            @Override
257            public void run() {
258                if (mBinaryDictionary == null) {
259                    return;
260                }
261                runGCIfRequiredLocked(mindsBlockByGC);
262            }
263        });
264    }
265
266    protected void runGCIfRequiredLocked(final boolean mindsBlockByGC) {
267        if (mBinaryDictionary.needsToRunGC(mindsBlockByGC)) {
268            mBinaryDictionary.flushWithGC();
269        }
270    }
271
272    /**
273     * Adds unigram information of a word to the dictionary. May overwrite an existing entry.
274     */
275    public void addUnigramEntryWithCheckingDistracter(final String word, final int frequency,
276            final String shortcutTarget, final int shortcutFreq, final boolean isNotAWord,
277            final boolean isBlacklisted, final int timestamp,
278            final DistracterFilter distracterFilter) {
279        reloadDictionaryIfRequired();
280        asyncExecuteTaskWithWriteLock(new Runnable() {
281            @Override
282            public void run() {
283                if (mBinaryDictionary == null) {
284                    return;
285                }
286                if (distracterFilter.isDistracterToWordsInDictionaries(
287                        PrevWordsInfo.EMPTY_PREV_WORDS_INFO, word, mLocale)) {
288                    // The word is a distracter.
289                    return;
290                }
291                runGCIfRequiredLocked(true /* mindsBlockByGC */);
292                addUnigramLocked(word, frequency, shortcutTarget, shortcutFreq,
293                        isNotAWord, isBlacklisted, timestamp);
294            }
295        });
296    }
297
298    protected void addUnigramLocked(final String word, final int frequency,
299            final String shortcutTarget, final int shortcutFreq, final boolean isNotAWord,
300            final boolean isBlacklisted, final int timestamp) {
301        if (!mBinaryDictionary.addUnigramEntry(word, frequency, shortcutTarget, shortcutFreq,
302                false /* isBeginningOfSentence */, isNotAWord, isBlacklisted, timestamp)) {
303            Log.e(TAG, "Cannot add unigram entry. word: " + word);
304        }
305    }
306
307    /**
308     * Dynamically remove the unigram entry from the dictionary.
309     */
310    public void removeUnigramEntryDynamically(final String word) {
311        reloadDictionaryIfRequired();
312        asyncExecuteTaskWithWriteLock(new Runnable() {
313            @Override
314            public void run() {
315                if (mBinaryDictionary == null) {
316                    return;
317                }
318                runGCIfRequiredLocked(true /* mindsBlockByGC */);
319                if (!mBinaryDictionary.removeUnigramEntry(word)) {
320                    if (DEBUG) {
321                        Log.i(TAG, "Cannot remove unigram entry: " + word);
322                    }
323                }
324            }
325        });
326    }
327
328    /**
329     * Adds n-gram information of a word to the dictionary. May overwrite an existing entry.
330     */
331    public void addNgramEntry(final PrevWordsInfo prevWordsInfo, final String word,
332            final int frequency, final int timestamp) {
333        reloadDictionaryIfRequired();
334        asyncExecuteTaskWithWriteLock(new Runnable() {
335            @Override
336            public void run() {
337                if (mBinaryDictionary == null) {
338                    return;
339                }
340                runGCIfRequiredLocked(true /* mindsBlockByGC */);
341                addNgramEntryLocked(prevWordsInfo, word, frequency, timestamp);
342            }
343        });
344    }
345
346    protected void addNgramEntryLocked(final PrevWordsInfo prevWordsInfo, final String word,
347            final int frequency, final int timestamp) {
348        if (!mBinaryDictionary.addNgramEntry(prevWordsInfo, word, frequency, timestamp)) {
349            if (DEBUG) {
350                Log.i(TAG, "Cannot add n-gram entry.");
351                Log.i(TAG, "  PrevWordsInfo: " + prevWordsInfo + ", word: " + word);
352            }
353        }
354    }
355
356    /**
357     * Dynamically remove the n-gram entry in the dictionary.
358     */
359    @UsedForTesting
360    public void removeNgramDynamically(final PrevWordsInfo prevWordsInfo, final String word) {
361        reloadDictionaryIfRequired();
362        asyncExecuteTaskWithWriteLock(new Runnable() {
363            @Override
364            public void run() {
365                if (mBinaryDictionary == null) {
366                    return;
367                }
368                runGCIfRequiredLocked(true /* mindsBlockByGC */);
369                if (!mBinaryDictionary.removeNgramEntry(prevWordsInfo, word)) {
370                    if (DEBUG) {
371                        Log.i(TAG, "Cannot remove n-gram entry.");
372                        Log.i(TAG, "  PrevWordsInfo: " + prevWordsInfo + ", word: " + word);
373                    }
374                }
375            }
376        });
377    }
378
379    public interface AddMultipleDictionaryEntriesCallback {
380        public void onFinished();
381    }
382
383    /**
384     * Dynamically add multiple entries to the dictionary.
385     */
386    public void addMultipleDictionaryEntriesDynamically(
387            final ArrayList<LanguageModelParam> languageModelParams,
388            final AddMultipleDictionaryEntriesCallback callback) {
389        reloadDictionaryIfRequired();
390        asyncExecuteTaskWithWriteLock(new Runnable() {
391            @Override
392            public void run() {
393                try {
394                    if (mBinaryDictionary == null) {
395                        return;
396                    }
397                    mBinaryDictionary.addMultipleDictionaryEntries(
398                            languageModelParams.toArray(
399                                    new LanguageModelParam[languageModelParams.size()]));
400                } finally {
401                    if (callback != null) {
402                        callback.onFinished();
403                    }
404                }
405            }
406        });
407    }
408
409    @Override
410    public ArrayList<SuggestedWordInfo> getSuggestions(final WordComposer composer,
411            final PrevWordsInfo prevWordsInfo, final ProximityInfo proximityInfo,
412            final boolean blockOffensiveWords, final int[] additionalFeaturesOptions,
413            final int sessionId, final float[] inOutLanguageWeight) {
414        reloadDictionaryIfRequired();
415        boolean lockAcquired = false;
416        try {
417            lockAcquired = mLock.readLock().tryLock(
418                    TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
419            if (lockAcquired) {
420                if (mBinaryDictionary == null) {
421                    return null;
422                }
423                final ArrayList<SuggestedWordInfo> suggestions =
424                        mBinaryDictionary.getSuggestions(composer, prevWordsInfo, proximityInfo,
425                                blockOffensiveWords, additionalFeaturesOptions, sessionId,
426                                inOutLanguageWeight);
427                if (mBinaryDictionary.isCorrupted()) {
428                    Log.i(TAG, "Dictionary (" + mDictName +") is corrupted. "
429                            + "Remove and regenerate it.");
430                    removeBinaryDictionary();
431                }
432                return suggestions;
433            }
434        } catch (final InterruptedException e) {
435            Log.e(TAG, "Interrupted tryLock() in getSuggestionsWithSessionId().", e);
436        } finally {
437            if (lockAcquired) {
438                mLock.readLock().unlock();
439            }
440        }
441        return null;
442    }
443
444    @Override
445    public boolean isInDictionary(final String word) {
446        reloadDictionaryIfRequired();
447        boolean lockAcquired = false;
448        try {
449            lockAcquired = mLock.readLock().tryLock(
450                    TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
451            if (lockAcquired) {
452                if (mBinaryDictionary == null) {
453                    return false;
454                }
455                return isInDictionaryLocked(word);
456            }
457        } catch (final InterruptedException e) {
458            Log.e(TAG, "Interrupted tryLock() in isInDictionary().", e);
459        } finally {
460            if (lockAcquired) {
461                mLock.readLock().unlock();
462            }
463        }
464        return false;
465    }
466
467    protected boolean isInDictionaryLocked(final String word) {
468        if (mBinaryDictionary == null) return false;
469        return mBinaryDictionary.isInDictionary(word);
470    }
471
472    @Override
473    public int getMaxFrequencyOfExactMatches(final String word) {
474        reloadDictionaryIfRequired();
475        boolean lockAcquired = false;
476        try {
477            lockAcquired = mLock.readLock().tryLock(
478                    TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
479            if (lockAcquired) {
480                if (mBinaryDictionary == null) {
481                    return NOT_A_PROBABILITY;
482                }
483                return mBinaryDictionary.getMaxFrequencyOfExactMatches(word);
484            }
485        } catch (final InterruptedException e) {
486            Log.e(TAG, "Interrupted tryLock() in getMaxFrequencyOfExactMatches().", e);
487        } finally {
488            if (lockAcquired) {
489                mLock.readLock().unlock();
490            }
491        }
492        return NOT_A_PROBABILITY;
493    }
494
495
496    protected boolean isValidNgramLocked(final PrevWordsInfo prevWordsInfo, final String word) {
497        if (mBinaryDictionary == null) return false;
498        return mBinaryDictionary.isValidNgram(prevWordsInfo, word);
499    }
500
501    /**
502     * Loads the current binary dictionary from internal storage. Assumes the dictionary file
503     * exists.
504     */
505    private void loadBinaryDictionaryLocked() {
506        if (DBG_STRESS_TEST) {
507            // Test if this class does not cause problems when it takes long time to load binary
508            // dictionary.
509            try {
510                Log.w(TAG, "Start stress in loading: " + mDictName);
511                Thread.sleep(15000);
512                Log.w(TAG, "End stress in loading");
513            } catch (InterruptedException e) {
514            }
515        }
516        final BinaryDictionary oldBinaryDictionary = mBinaryDictionary;
517        openBinaryDictionaryLocked();
518        if (oldBinaryDictionary != null) {
519            oldBinaryDictionary.close();
520        }
521        if (mBinaryDictionary.isValidDictionary()
522                && needsToMigrateDictionary(mBinaryDictionary.getFormatVersion())) {
523            if (!mBinaryDictionary.migrateTo(DICTIONARY_FORMAT_VERSION)) {
524                Log.e(TAG, "Dictionary migration failed: " + mDictName);
525                removeBinaryDictionaryLocked();
526            }
527        }
528    }
529
530    /**
531     * Create a new binary dictionary and load initial contents.
532     */
533    private void createNewDictionaryLocked() {
534        removeBinaryDictionaryLocked();
535        createOnMemoryBinaryDictionaryLocked();
536        loadInitialContentsLocked();
537        // Run GC and flush to file when initial contents have been loaded.
538        mBinaryDictionary.flushWithGCIfHasUpdated();
539    }
540
541    /**
542     * Marks that the dictionary needs to be recreated.
543     *
544     */
545    protected void setNeedsToRecreate() {
546        mNeedsToRecreate = true;
547    }
548
549    /**
550     * Load the current binary dictionary from internal storage. If the dictionary file doesn't
551     * exists or needs to be regenerated, the new dictionary file will be asynchronously generated.
552     * However, the dictionary itself is accessible even before the new dictionary file is actually
553     * generated. It may return a null result for getSuggestions() in that case by design.
554     */
555    public final void reloadDictionaryIfRequired() {
556        if (!isReloadRequired()) return;
557        asyncReloadDictionary();
558    }
559
560    /**
561     * Returns whether a dictionary reload is required.
562     */
563    private boolean isReloadRequired() {
564        return mBinaryDictionary == null || mNeedsToRecreate;
565    }
566
567    /**
568     * Reloads the dictionary. Access is controlled on a per dictionary file basis.
569     */
570    private final void asyncReloadDictionary() {
571        if (mIsReloading.compareAndSet(false, true)) {
572            asyncExecuteTaskWithWriteLock(new Runnable() {
573                @Override
574                public void run() {
575                    try {
576                        if (!mDictFile.exists() || mNeedsToRecreate) {
577                            // If the dictionary file does not exist or contents have been updated,
578                            // generate a new one.
579                            createNewDictionaryLocked();
580                        } else if (mBinaryDictionary == null) {
581                            // Otherwise, load the existing dictionary.
582                            loadBinaryDictionaryLocked();
583                            if (mBinaryDictionary != null && !(isValidDictionaryLocked()
584                                    // TODO: remove the check below
585                                    && matchesExpectedBinaryDictFormatVersionForThisType(
586                                            mBinaryDictionary.getFormatVersion()))) {
587                                // Binary dictionary or its format version is not valid. Regenerate
588                                // the dictionary file. createNewDictionaryLocked will remove the
589                                // existing files if appropriate.
590                                createNewDictionaryLocked();
591                            }
592                        }
593                        mNeedsToRecreate = false;
594                    } finally {
595                        mIsReloading.set(false);
596                    }
597                }
598            });
599        }
600    }
601
602    /**
603     * Flush binary dictionary to dictionary file.
604     */
605    public void asyncFlushBinaryDictionary() {
606        asyncExecuteTaskWithWriteLock(new Runnable() {
607            @Override
608            public void run() {
609                if (mBinaryDictionary == null) {
610                    return;
611                }
612                if (mBinaryDictionary.needsToRunGC(false /* mindsBlockByGC */)) {
613                    mBinaryDictionary.flushWithGC();
614                } else {
615                    mBinaryDictionary.flush();
616                }
617            }
618        });
619    }
620
621    @UsedForTesting
622    public void waitAllTasksForTests() {
623        final CountDownLatch countDownLatch = new CountDownLatch(1);
624        ExecutorUtils.getExecutor(mDictName).execute(new Runnable() {
625            @Override
626            public void run() {
627                countDownLatch.countDown();
628            }
629        });
630        try {
631            countDownLatch.await();
632        } catch (InterruptedException e) {
633            Log.e(TAG, "Interrupted while waiting for finishing dictionary operations.", e);
634        }
635    }
636
637    @UsedForTesting
638    public void clearAndFlushDictionaryWithAdditionalAttributes(
639            final Map<String, String> attributeMap) {
640        mAdditionalAttributeMap = attributeMap;
641        clear();
642    }
643
644    public void dumpAllWordsForDebug() {
645        reloadDictionaryIfRequired();
646        asyncExecuteTaskWithLock(mLock.readLock(), new Runnable() {
647            @Override
648            public void run() {
649                Log.d(TAG, "Dump dictionary: " + mDictName);
650                try {
651                    final DictionaryHeader header = mBinaryDictionary.getHeader();
652                    Log.d(TAG, "Format version: " + mBinaryDictionary.getFormatVersion());
653                    Log.d(TAG, CombinedFormatUtils.formatAttributeMap(
654                            header.mDictionaryOptions.mAttributes));
655                } catch (final UnsupportedFormatException e) {
656                    Log.d(TAG, "Cannot fetch header information.", e);
657                }
658                int token = 0;
659                do {
660                    final BinaryDictionary.GetNextWordPropertyResult result =
661                            mBinaryDictionary.getNextWordProperty(token);
662                    final WordProperty wordProperty = result.mWordProperty;
663                    if (wordProperty == null) {
664                        Log.d(TAG, " dictionary is empty.");
665                        break;
666                    }
667                    Log.d(TAG, wordProperty.toString());
668                    token = result.mNextToken;
669                } while (token != 0);
670            }
671        });
672    }
673}
674