Make the DictionaryService stage the downloaded files
Bug: 20641948 Change-Id: I6639c995b12c033bc30241cd219201dd483ee516main
parent
459b4f353e
commit
a0d9c82921
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package com.android.inputmethod.latin.common;
|
package com.android.inputmethod.latin.common;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FilenameFilter;
|
import java.io.FilenameFilter;
|
||||||
|
|
||||||
|
@ -23,6 +25,8 @@ import java.io.FilenameFilter;
|
||||||
* A simple class to help with removing directories recursively.
|
* A simple class to help with removing directories recursively.
|
||||||
*/
|
*/
|
||||||
public class FileUtils {
|
public class FileUtils {
|
||||||
|
private static final String TAG = "FileUtils";
|
||||||
|
|
||||||
public static boolean deleteRecursively(final File path) {
|
public static boolean deleteRecursively(final File path) {
|
||||||
if (path.isDirectory()) {
|
if (path.isDirectory()) {
|
||||||
final File[] files = path.listFiles();
|
final File[] files = path.listFiles();
|
||||||
|
@ -51,4 +55,14 @@ public class FileUtils {
|
||||||
}
|
}
|
||||||
return hasDeletedAllFiles;
|
return hasDeletedAllFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean renameTo(final File fromFile, final File toFile) {
|
||||||
|
toFile.delete();
|
||||||
|
final boolean success = fromFile.renameTo(toFile);
|
||||||
|
if (!success) {
|
||||||
|
Log.e(TAG, String.format("Failed to rename from %s to %s.",
|
||||||
|
fromFile.getAbsoluteFile(), toFile.getAbsoluteFile()));
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,9 @@ import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.android.inputmethod.compat.DownloadManagerCompatUtils;
|
import com.android.inputmethod.compat.DownloadManagerCompatUtils;
|
||||||
|
import com.android.inputmethod.latin.BinaryDictionaryFileDumper;
|
||||||
import com.android.inputmethod.latin.R;
|
import com.android.inputmethod.latin.R;
|
||||||
|
import com.android.inputmethod.latin.common.LocaleUtils;
|
||||||
import com.android.inputmethod.latin.utils.ApplicationUtils;
|
import com.android.inputmethod.latin.utils.ApplicationUtils;
|
||||||
import com.android.inputmethod.latin.utils.DebugLogUtils;
|
import com.android.inputmethod.latin.utils.DebugLogUtils;
|
||||||
|
|
||||||
|
@ -210,9 +212,17 @@ public final class ActionBatch {
|
||||||
+ " for an InstallAfterDownload action. Bailing out.");
|
+ " for an InstallAfterDownload action. Bailing out.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugLogUtils.l("Setting word list as installed");
|
DebugLogUtils.l("Setting word list as installed");
|
||||||
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
|
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
|
||||||
MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
|
MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
|
||||||
|
|
||||||
|
// Install the downloaded file by un-compressing and moving it to the staging
|
||||||
|
// directory. Ideally, we should do this before updating the DB, but the
|
||||||
|
// installDictToStagingFromContentProvider() relies on the db being updated.
|
||||||
|
final String localeString = mWordListValues.getAsString(MetadataDbHelper.LOCALE_COLUMN);
|
||||||
|
BinaryDictionaryFileDumper.installDictToStagingFromContentProvider(
|
||||||
|
LocaleUtils.constructLocaleFromString(localeString), context, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -592,6 +592,8 @@ public final class UpdateHandler {
|
||||||
* Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
|
* Warn Android Keyboard that the state of dictionaries changed and it should refresh its data.
|
||||||
*/
|
*/
|
||||||
private static void signalNewDictionaryState(final Context context) {
|
private static void signalNewDictionaryState(final Context context) {
|
||||||
|
// TODO: Also provide the locale of the updated dictionary so that the LatinIme
|
||||||
|
// does not have to reset if it is a different locale.
|
||||||
final Intent newDictBroadcast =
|
final Intent newDictBroadcast =
|
||||||
new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION);
|
new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION);
|
||||||
context.sendBroadcast(newDictBroadcast);
|
context.sendBroadcast(newDictBroadcast);
|
||||||
|
|
|
@ -29,6 +29,7 @@ import android.util.Log;
|
||||||
|
|
||||||
import com.android.inputmethod.dictionarypack.DictionaryPackConstants;
|
import com.android.inputmethod.dictionarypack.DictionaryPackConstants;
|
||||||
import com.android.inputmethod.dictionarypack.MD5Calculator;
|
import com.android.inputmethod.dictionarypack.MD5Calculator;
|
||||||
|
import com.android.inputmethod.latin.common.FileUtils;
|
||||||
import com.android.inputmethod.latin.define.DecoderSpecificConstants;
|
import com.android.inputmethod.latin.define.DecoderSpecificConstants;
|
||||||
import com.android.inputmethod.latin.utils.DictionaryInfoUtils;
|
import com.android.inputmethod.latin.utils.DictionaryInfoUtils;
|
||||||
import com.android.inputmethod.latin.utils.DictionaryInfoUtils.DictionaryInfo;
|
import com.android.inputmethod.latin.utils.DictionaryInfoUtils.DictionaryInfo;
|
||||||
|
@ -220,11 +221,11 @@ public final class BinaryDictionaryFileDumper {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caches a word list the id of which is passed as an argument. This will write the file
|
* Stages a word list the id of which is passed as an argument. This will write the file
|
||||||
* to the cache file name designated by its id and locale, overwriting it if already present
|
* to the cache file name designated by its id and locale, overwriting it if already present
|
||||||
* and creating it (and its containing directory) if necessary.
|
* and creating it (and its containing directory) if necessary.
|
||||||
*/
|
*/
|
||||||
private static void cacheWordList(final String wordlistId, final String locale,
|
private static void installWordListToStaging(final String wordlistId, final String locale,
|
||||||
final String rawChecksum, final ContentProviderClient providerClient,
|
final String rawChecksum, final ContentProviderClient providerClient,
|
||||||
final Context context) {
|
final Context context) {
|
||||||
final int COMPRESSED_CRYPTED_COMPRESSED = 0;
|
final int COMPRESSED_CRYPTED_COMPRESSED = 0;
|
||||||
|
@ -246,7 +247,7 @@ public final class BinaryDictionaryFileDumper {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final String finalFileName =
|
final String finalFileName =
|
||||||
DictionaryInfoUtils.getCacheFileName(wordlistId, locale, context);
|
DictionaryInfoUtils.getStagingFileName(wordlistId, locale, context);
|
||||||
String tempFileName;
|
String tempFileName;
|
||||||
try {
|
try {
|
||||||
tempFileName = BinaryDictionaryGetter.getTempFileName(wordlistId, context);
|
tempFileName = BinaryDictionaryGetter.getTempFileName(wordlistId, context);
|
||||||
|
@ -320,23 +321,21 @@ public final class BinaryDictionaryFileDumper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// move the output file to the final staging file.
|
||||||
final File finalFile = new File(finalFileName);
|
final File finalFile = new File(finalFileName);
|
||||||
finalFile.delete();
|
FileUtils.renameTo(outputFile, finalFile);
|
||||||
if (!outputFile.renameTo(finalFile)) {
|
|
||||||
throw new IOException("Can't move the file to its final name");
|
|
||||||
}
|
|
||||||
wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT,
|
wordListUriBuilder.appendQueryParameter(QUERY_PARAMETER_DELETE_RESULT,
|
||||||
QUERY_PARAMETER_SUCCESS);
|
QUERY_PARAMETER_SUCCESS);
|
||||||
if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) {
|
if (0 >= providerClient.delete(wordListUriBuilder.build(), null, null)) {
|
||||||
Log.e(TAG, "Could not have the dictionary pack delete a word list");
|
Log.e(TAG, "Could not have the dictionary pack delete a word list");
|
||||||
}
|
}
|
||||||
BinaryDictionaryGetter.removeFilesWithIdExcept(context, wordlistId, finalFile);
|
Log.d(TAG, "Successfully copied file for wordlist ID " + wordlistId);
|
||||||
Log.e(TAG, "Successfully copied file for wordlist ID " + wordlistId);
|
|
||||||
// Success! Close files (through the finally{} clause) and return.
|
// Success! Close files (through the finally{} clause) and return.
|
||||||
return;
|
return;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.i(TAG, "Can't open word list in mode " + mode, e);
|
Log.e(TAG, "Can't open word list in mode " + mode, e);
|
||||||
}
|
}
|
||||||
if (null != outputFile) {
|
if (null != outputFile) {
|
||||||
// This may or may not fail. The file may not have been created if the
|
// This may or may not fail. The file may not have been created if the
|
||||||
|
@ -403,7 +402,7 @@ public final class BinaryDictionaryFileDumper {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries a content provider for word list data for some locale and cache the returned files
|
* Queries a content provider for word list data for some locale and stage the returned files
|
||||||
*
|
*
|
||||||
* This will query a content provider for word list data for a given locale, and copy the
|
* This will query a content provider for word list data for a given locale, and copy the
|
||||||
* files locally so that they can be mmap'ed. This may overwrite previously cached word lists
|
* files locally so that they can be mmap'ed. This may overwrite previously cached word lists
|
||||||
|
@ -411,7 +410,7 @@ public final class BinaryDictionaryFileDumper {
|
||||||
* @throw FileNotFoundException if the provider returns non-existent data.
|
* @throw FileNotFoundException if the provider returns non-existent data.
|
||||||
* @throw IOException if the provider-returned data could not be read.
|
* @throw IOException if the provider-returned data could not be read.
|
||||||
*/
|
*/
|
||||||
public static void cacheWordListsFromContentProvider(final Locale locale,
|
public static void installDictToStagingFromContentProvider(final Locale locale,
|
||||||
final Context context, final boolean hasDefaultWordList) {
|
final Context context, final boolean hasDefaultWordList) {
|
||||||
final ContentProviderClient providerClient;
|
final ContentProviderClient providerClient;
|
||||||
try {
|
try {
|
||||||
|
@ -429,13 +428,27 @@ public final class BinaryDictionaryFileDumper {
|
||||||
final List<WordListInfo> idList = getWordListWordListInfos(locale, context,
|
final List<WordListInfo> idList = getWordListWordListInfos(locale, context,
|
||||||
hasDefaultWordList);
|
hasDefaultWordList);
|
||||||
for (WordListInfo id : idList) {
|
for (WordListInfo id : idList) {
|
||||||
cacheWordList(id.mId, id.mLocale, id.mRawChecksum, providerClient, context);
|
installWordListToStaging(id.mId, id.mLocale, id.mRawChecksum, providerClient,
|
||||||
|
context);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
providerClient.release();
|
providerClient.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the dictionary if it was never requested/used.
|
||||||
|
*
|
||||||
|
* @param locale locale to download
|
||||||
|
* @param context the context for resources and providers.
|
||||||
|
* @param hasDefaultWordList whether the default wordlist exists in the resources.
|
||||||
|
*/
|
||||||
|
public static void downloadDictIfNeverRequested(final Locale locale,
|
||||||
|
final Context context, final boolean hasDefaultWordList) {
|
||||||
|
Log.d("inamul_tag", "BinaryDictionaryFileDumper.downloadDictIfNeverRequested()");
|
||||||
|
getWordListWordListInfos(locale, context, hasDefaultWordList);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies the data in an input stream to a target file if the magic number matches.
|
* Copies the data in an input stream to a target file if the magic number matches.
|
||||||
*
|
*
|
||||||
|
|
|
@ -195,39 +195,6 @@ final public class BinaryDictionaryGetter {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all files with the passed id, except the passed file.
|
|
||||||
*
|
|
||||||
* If a dictionary with a given ID has a metadata change that causes it to change
|
|
||||||
* path, we need to remove the old version. The only way to do this is to check all
|
|
||||||
* installed files for a matching ID in a different directory.
|
|
||||||
*/
|
|
||||||
public static void removeFilesWithIdExcept(final Context context, final String id,
|
|
||||||
final File fileToKeep) {
|
|
||||||
try {
|
|
||||||
final File canonicalFileToKeep = fileToKeep.getCanonicalFile();
|
|
||||||
final File[] directoryList = DictionaryInfoUtils.getCachedDirectoryList(context);
|
|
||||||
if (null == directoryList) return;
|
|
||||||
for (File directory : directoryList) {
|
|
||||||
// There is one directory per locale. See #getCachedDirectoryList
|
|
||||||
if (!directory.isDirectory()) continue;
|
|
||||||
final File[] wordLists = directory.listFiles();
|
|
||||||
if (null == wordLists) continue;
|
|
||||||
for (File wordList : wordLists) {
|
|
||||||
final String fileId =
|
|
||||||
DictionaryInfoUtils.getWordListIdFromFileName(wordList.getName());
|
|
||||||
if (fileId.equals(id)) {
|
|
||||||
if (!canonicalFileToKeep.equals(wordList.getCanonicalFile())) {
|
|
||||||
wordList.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (java.io.IOException e) {
|
|
||||||
Log.e(TAG, "IOException trying to cleanup files", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since
|
// ## HACK ## we prevent usage of a dictionary before version 18. The reason for this is, since
|
||||||
// those do not include whitelist entries, the new code with an old version of the dictionary
|
// those do not include whitelist entries, the new code with an old version of the dictionary
|
||||||
// would lose whitelist functionality.
|
// would lose whitelist functionality.
|
||||||
|
@ -274,12 +241,18 @@ final public class BinaryDictionaryGetter {
|
||||||
*/
|
*/
|
||||||
public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale,
|
public static ArrayList<AssetFileAddress> getDictionaryFiles(final Locale locale,
|
||||||
final Context context, boolean notifyDictionaryPackForUpdates) {
|
final Context context, boolean notifyDictionaryPackForUpdates) {
|
||||||
|
|
||||||
final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable(
|
|
||||||
context, locale);
|
|
||||||
if (notifyDictionaryPackForUpdates) {
|
if (notifyDictionaryPackForUpdates) {
|
||||||
BinaryDictionaryFileDumper.cacheWordListsFromContentProvider(locale, context,
|
final boolean hasDefaultWordList = DictionaryInfoUtils.isDictionaryAvailable(
|
||||||
hasDefaultWordList);
|
context, locale);
|
||||||
|
// It makes sure that the first time keyboard comes up and the dictionaries are reset,
|
||||||
|
// the DB is populated with the appropriate values for each locale. Helps in downloading
|
||||||
|
// the dictionaries when the user enables and switches new languages before the
|
||||||
|
// DictionaryService runs.
|
||||||
|
BinaryDictionaryFileDumper.downloadDictIfNeverRequested(
|
||||||
|
locale, context, hasDefaultWordList);
|
||||||
|
|
||||||
|
// Move a staging files to the cache ddirectories if any.
|
||||||
|
DictionaryInfoUtils.moveStagingFilesIfExists(context);
|
||||||
}
|
}
|
||||||
final File[] cachedWordLists = getCachedWordLists(locale.toString(), context);
|
final File[] cachedWordLists = getCachedWordLists(locale.toString(), context);
|
||||||
final String mainDictId = DictionaryInfoUtils.getMainDictId(locale);
|
final String mainDictId = DictionaryInfoUtils.getMainDictId(locale);
|
||||||
|
|
|
@ -30,6 +30,7 @@ import com.android.inputmethod.latin.AssetFileAddress;
|
||||||
import com.android.inputmethod.latin.BinaryDictionaryGetter;
|
import com.android.inputmethod.latin.BinaryDictionaryGetter;
|
||||||
import com.android.inputmethod.latin.R;
|
import com.android.inputmethod.latin.R;
|
||||||
import com.android.inputmethod.latin.RichInputMethodManager;
|
import com.android.inputmethod.latin.RichInputMethodManager;
|
||||||
|
import com.android.inputmethod.latin.common.FileUtils;
|
||||||
import com.android.inputmethod.latin.common.LocaleUtils;
|
import com.android.inputmethod.latin.common.LocaleUtils;
|
||||||
import com.android.inputmethod.latin.define.DecoderSpecificConstants;
|
import com.android.inputmethod.latin.define.DecoderSpecificConstants;
|
||||||
import com.android.inputmethod.latin.makedict.DictionaryHeader;
|
import com.android.inputmethod.latin.makedict.DictionaryHeader;
|
||||||
|
@ -152,6 +153,13 @@ public class DictionaryInfoUtils {
|
||||||
return context.getFilesDir() + File.separator + "dicts";
|
return context.getFilesDir() + File.separator + "dicts";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to get the top level cache directory.
|
||||||
|
*/
|
||||||
|
public static String getWordListStagingDirectory(final Context context) {
|
||||||
|
return context.getFilesDir() + File.separator + "staging";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to get the top level temp directory.
|
* Helper method to get the top level temp directory.
|
||||||
*/
|
*/
|
||||||
|
@ -188,6 +196,10 @@ public class DictionaryInfoUtils {
|
||||||
return new File(DictionaryInfoUtils.getWordListCacheDirectory(context)).listFiles();
|
return new File(DictionaryInfoUtils.getWordListCacheDirectory(context)).listFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static File[] getStagingDirectoryList(final Context context) {
|
||||||
|
return new File(DictionaryInfoUtils.getWordListStagingDirectory(context)).listFiles();
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static File[] getUnusedDictionaryList(final Context context) {
|
public static File[] getUnusedDictionaryList(final Context context) {
|
||||||
return context.getFilesDir().listFiles(new FilenameFilter() {
|
return context.getFilesDir().listFiles(new FilenameFilter() {
|
||||||
|
@ -221,7 +233,7 @@ public class DictionaryInfoUtils {
|
||||||
/**
|
/**
|
||||||
* Find out the cache directory associated with a specific locale.
|
* Find out the cache directory associated with a specific locale.
|
||||||
*/
|
*/
|
||||||
private static String getCacheDirectoryForLocale(final String locale, final Context context) {
|
public static String getCacheDirectoryForLocale(final String locale, final Context context) {
|
||||||
final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale);
|
final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale);
|
||||||
final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator
|
final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator
|
||||||
+ relativeDirectoryName;
|
+ relativeDirectoryName;
|
||||||
|
@ -254,6 +266,52 @@ public class DictionaryInfoUtils {
|
||||||
return getCacheDirectoryForLocale(locale, context) + File.separator + fileName;
|
return getCacheDirectoryForLocale(locale, context) + File.separator + fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String getStagingFileName(String id, String locale, Context context) {
|
||||||
|
final String stagingDirectory = getWordListStagingDirectory(context);
|
||||||
|
// create the directory if it does not exist.
|
||||||
|
final File directory = new File(stagingDirectory);
|
||||||
|
if (!directory.exists()) {
|
||||||
|
if (!directory.mkdirs()) {
|
||||||
|
Log.e(TAG, "Could not create the staging directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// e.g. id="main:en_in", locale ="en_IN"
|
||||||
|
final String fileName = replaceFileNameDangerousCharacters(
|
||||||
|
locale + TEMP_DICT_FILE_SUB + id);
|
||||||
|
return stagingDirectory + File.separator + fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void moveStagingFilesIfExists(Context context) {
|
||||||
|
final File[] stagingFiles = DictionaryInfoUtils.getStagingDirectoryList(context);
|
||||||
|
if (stagingFiles != null && stagingFiles.length > 0) {
|
||||||
|
for (final File stagingFile : stagingFiles) {
|
||||||
|
final String fileName = stagingFile.getName();
|
||||||
|
final int index = fileName.indexOf(TEMP_DICT_FILE_SUB);
|
||||||
|
if (index == -1) {
|
||||||
|
// This should never happen.
|
||||||
|
Log.e(TAG, "Staging file does not have ___ substring.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String[] localeAndFileId = fileName.split(TEMP_DICT_FILE_SUB);
|
||||||
|
if (localeAndFileId.length != 2) {
|
||||||
|
Log.e(TAG, String.format("malformed staging file %s. Deleting.",
|
||||||
|
stagingFile.getAbsoluteFile()));
|
||||||
|
stagingFile.delete();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String locale = localeAndFileId[0];
|
||||||
|
// already escaped while moving to staging.
|
||||||
|
final String fileId = localeAndFileId[1];
|
||||||
|
final String cacheDirectoryForLocale = getCacheDirectoryForLocale(locale, context);
|
||||||
|
final String cacheFilename = cacheDirectoryForLocale + File.separator + fileId;
|
||||||
|
final File cacheFile = new File(cacheFilename);
|
||||||
|
// move the staging file to cache file.
|
||||||
|
FileUtils.renameTo(stagingFile, cacheFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static boolean isMainWordListId(final String id) {
|
public static boolean isMainWordListId(final String id) {
|
||||||
final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
|
final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
|
||||||
// An id is supposed to be in format category:locale, so splitting on the separator
|
// An id is supposed to be in format category:locale, so splitting on the separator
|
||||||
|
|
Loading…
Reference in New Issue