allow users to select dictionaries

https://github.com/openboard-team/openboard/pull/569
https://github.com/openboard-team/openboard/pull/578
modified so dictionaries use the correct type instead of always main
This commit is contained in:
Helium314 2023-06-26 13:25:32 +02:00
parent 62e55fd70b
commit 29c252066d
62 changed files with 502 additions and 16 deletions

View file

@ -21,6 +21,7 @@ import android.content.SharedPreferences;
import android.content.res.AssetFileDescriptor;
import android.util.Log;
import org.dslul.openboard.inputmethod.latin.common.FileUtils;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import org.dslul.openboard.inputmethod.latin.define.DecoderSpecificConstants;
import org.dslul.openboard.inputmethod.latin.makedict.DictionaryHeader;
@ -62,6 +63,8 @@ final public class BinaryDictionaryGetter {
public static final String MAIN_DICTIONARY_CATEGORY = "main";
public static final String ID_CATEGORY_SEPARATOR = ":";
public static final String ASSETS_DICTIONARY_FOLDER = "dicts";
// The key considered to read the version attribute in a dictionary file.
private static String VERSION_KEY = "version";
@ -170,8 +173,8 @@ final public class BinaryDictionaryGetter {
for (File directory : directoryList) {
if (!directory.isDirectory()) continue;
final String dirLocale =
DictionaryInfoUtils.getWordListIdFromFileName(directory.getName());
final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale);
DictionaryInfoUtils.getWordListIdFromFileName(directory.getName()).toLowerCase(Locale.ENGLISH);
final int matchLevel = LocaleUtils.getMatchLevel(dirLocale, locale.toLowerCase(Locale.ENGLISH));
if (LocaleUtils.isMatch(matchLevel)) {
final File[] wordLists = directory.listFiles();
if (null != wordLists) {
@ -265,9 +268,16 @@ final public class BinaryDictionaryGetter {
}
if (!foundMainDict && dictPackSettings.isWordListActive(mainDictId)) {
final int fallbackResId =
DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale);
final AssetFileAddress fallbackAsset = loadFallbackResource(context, fallbackResId);
final File dict = loadDictionaryFromAssets(locale.toString(), context);
final AssetFileAddress fallbackAsset;
if (dict == null) {
// fall back to the old way (maybe remove? will not work if files are compressed)
final int fallbackResId =
DictionaryInfoUtils.getMainDictionaryResourceId(context.getResources(), locale);
fallbackAsset = loadFallbackResource(context, fallbackResId);
} else {
fallbackAsset = AssetFileAddress.makeFromFileName(dict.getPath());
}
if (null != fallbackAsset) {
fileList.add(fallbackAsset);
}
@ -275,4 +285,75 @@ final public class BinaryDictionaryGetter {
return fileList;
}
/**
* Returns the best matching main dictionary from assets.
*
* Actually copies the dictionary to cache folder, and then returns that file. This allows
* the dictionaries to be stored in a compressed way, reducing APK size.
* On next load, the dictionary in cache folder is found by getCachedWordLists
*
* Returns null on IO errors or if no matching dictionary is found
*/
public static File loadDictionaryFromAssets(final String locale, final Context context) {
final String[] dictionaryList = getAssetsDictionaryList(context);
if (null == dictionaryList) return null;
String bestMatchName = null;
int bestMatchLevel = 0;
for (String dictionary : dictionaryList) {
final String dictLocale =
extractLocaleFromAssetsDictionaryFile(dictionary);
if (dictLocale == null) continue;
// assets files may contain the locale in lowercase, but dictionary headers usually
// have an upper case country code, so we compare lowercase here
final int matchLevel = LocaleUtils.getMatchLevel(dictLocale.toLowerCase(Locale.ENGLISH), locale.toLowerCase(Locale.ENGLISH));
if (LocaleUtils.isMatch(matchLevel) && matchLevel > bestMatchLevel) {
bestMatchName = dictionary;
}
}
if (bestMatchName == null) return null;
// we have a match, now copy contents of the dictionary to cached word lists folder
final String bestMatchLocale = extractLocaleFromAssetsDictionaryFile(bestMatchName);
if (bestMatchLocale == null) return null;
File dictFile = new File(DictionaryInfoUtils.getCacheDirectoryForLocale(bestMatchLocale, context) +
File.separator + DictionaryInfoUtils.getMainDictFilename(bestMatchLocale));
try {
FileUtils.copyStreamToNewFile(
context.getAssets().open(ASSETS_DICTIONARY_FOLDER + File.separator + bestMatchName),
dictFile);
return dictFile;
} catch (IOException e) {
Log.e(TAG, "exception while looking for locale " + locale, e);
return null;
}
}
/**
* Returns the locale for a dictionary file name stored in assets.
*
* Assumes file name main_[locale].dict
*
* Returns the locale, or null if file name does not match the pattern
*/
public static String extractLocaleFromAssetsDictionaryFile(final String dictionaryFileName) {
if (dictionaryFileName.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX)
&& dictionaryFileName.endsWith(".dict")) {
return dictionaryFileName.substring(
DictionaryInfoUtils.MAIN_DICT_PREFIX.length(),
dictionaryFileName.lastIndexOf('.')
);
}
return null;
}
public static String[] getAssetsDictionaryList(final Context context) {
final String[] dictionaryList;
try {
dictionaryList = context.getAssets().list(ASSETS_DICTIONARY_FOLDER);
} catch (IOException e) {
return null;
}
return dictionaryList;
}
}

View file

@ -17,7 +17,10 @@
package org.dslul.openboard.inputmethod.latin.common;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
/**
* A simple class to help with removing directories recursively.
@ -58,4 +61,19 @@ public class FileUtils {
toFile.delete();
return fromFile.renameTo(toFile);
}
public static void copyStreamToNewFile(InputStream in, File outfile) throws IOException {
File parentFile = outfile.getParentFile();
if (parentFile == null || (!parentFile.exists() && !parentFile.mkdirs())) {
throw new IOException("could not create parent folder");
}
FileOutputStream out = new FileOutputStream(outfile);
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
out.flush();
}
}

View file

@ -0,0 +1,329 @@
package org.dslul.openboard.inputmethod.latin.settings
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.preference.Preference
import android.text.Html
import android.text.method.LinkMovementMethod
import android.util.TypedValue
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import android.widget.Toast
import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants
import org.dslul.openboard.inputmethod.latin.BinaryDictionaryGetter
import org.dslul.openboard.inputmethod.latin.R
import org.dslul.openboard.inputmethod.latin.common.FileUtils
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils
import org.dslul.openboard.inputmethod.latin.utils.DialogUtils
import org.dslul.openboard.inputmethod.latin.utils.DictionaryInfoUtils
import java.io.File
import java.io.IOException
import java.util.*
class DictionarySettingsFragment : SubScreenFragment() {
// dict for which dialog is currently open (if any)
private var currentDictLocale: Locale? = null
private var currentDictState: Int? = null
private val cachedDictionaryFile by lazy { File(activity.cacheDir.path + File.separator + "temp_dict") }
private val currentDictExistsForUser get() = currentDictState == DICT_INTERNAL_AND_USER || currentDictState == DICT_USER_ONLY
private val currentDictExistsInternal get() = currentDictState == DICT_INTERNAL_AND_USER || currentDictState == DICT_INTERNAL_ONLY
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.additional_subtype_settings)
reloadDictionaries()
// + button to add dictionary
setHasOptionsMenu(true)
activity.actionBar?.setTitle(R.string.dictionary_settings_category)
}
// shows existing dictionaries as preferences
private fun reloadDictionaries() {
val screen = preferenceScreen ?: return
screen.removeAll()
val userDicts = mutableSetOf<Locale>()
val internalDicts = mutableSetOf<Locale>()
// get available dictionaries
// cached (internal in use and user dicts)
DictionaryInfoUtils.getCachedDirectoryList(activity)?.forEach { dir ->
if (!dir.isDirectory)
return@forEach
dir.list()?.forEach {
if (it.endsWith(USER_DICTIONARY_SUFFIX))
userDicts.add(dir.name.toLocale())
else if (it.startsWith(DictionaryInfoUtils.MAIN_DICT_PREFIX))
internalDicts.add(dir.name.toLocale())
}
}
// internal only
BinaryDictionaryGetter.getAssetsDictionaryList(activity)?.forEach { dictFile ->
BinaryDictionaryGetter.extractLocaleFromAssetsDictionaryFile(dictFile)?.let {
internalDicts.add(it.toLocale())
}
}
// first show user-added dictionaries
userDicts.sortedBy { it.displayName() }.forEach { dict ->
val pref = Preference(activity).apply {
title = dict.displayName()
setSummary(R.string.user_dictionary_summary)
setOnPreferenceClickListener {
// open dialog for update or delete / reset
currentDictLocale = dict
currentDictState = if (internalDicts.contains(dict)) DICT_INTERNAL_AND_USER else DICT_USER_ONLY
showUpdateDialog()
true
}
}
screen.addPreference(pref)
}
// TODO: only show if language is actually used?
internalDicts.sortedBy { it.displayName() }.forEach { dict ->
if (userDicts.contains(dict)) return@forEach // don't show a second time
val pref = Preference(activity).apply {
title = dict.displayName()
setSummary(R.string.internal_dictionary_summary)
setOnPreferenceClickListener {
// open dialog for update, maybe disabling if i can make it work?
currentDictLocale = dict
currentDictState = DICT_INTERNAL_ONLY
showUpdateDialog()
true
}
}
screen.addPreference(pref)
}
}
private fun showUpdateDialog() {
// -1: adding new dict, don't know where it may exist
// 0: user only -> offer delete
// 1: internal only -> only update (and maybe later: disable)
// 2: user and internal -> offer reset to internal
if (currentDictState == null) return
if (currentDictLocale == null && currentDictState != DICT_NEW)
return
val link = "<a href='$DICTIONARY_URL'>" +
resources.getString(R.string.dictionary_link_text) + "</a>"
val message = if (currentDictState == DICT_NEW)
Html.fromHtml(resources.getString(R.string.add_new_dictionary, link))
else
Html.fromHtml(resources.getString(R.string.update_dictionary, link))
val title = if (currentDictState == DICT_NEW) R.string.add_new_dictionary_title
else R.string.dictionary_settings_category
val updateButtonTitle = if (currentDictExistsForUser) R.string.update_dictionary_button
else R.string.user_dict_settings_add_menu_title
val builder = AlertDialog.Builder(DialogUtils.getPlatformDialogThemeContext(activity))
.setNegativeButton(R.string.cancel, null)
.setMessage(message)
.setTitle(title)
.setPositiveButton(updateButtonTitle) { _, _ ->
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
startActivityForResult(intent, DICTIONARY_REQUEST_CODE)
}
// allow removing dictionaries
if (currentDictExistsForUser) {
builder.setNeutralButton(if (currentDictExistsInternal) R.string.reset_dictionary else R.string.delete_dict) { _, _ ->
AlertDialog.Builder(DialogUtils.getPlatformDialogThemeContext(activity))
.setTitle(R.string.remove_dictionary_title)
.setMessage(resources.getString(R.string.remove_dictionary_message, currentDictLocale?.displayName()))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.delete_dict) { _,_ ->
currentDictLocale?.getUserDictFilenames()?.let { files ->
var parent: File? = null
files.forEach {
val f = File(it)
parent = f.parentFile
f.delete()
}
if (parent?.list()?.isEmpty() == true)
parent?.delete()
}
reloadDictionaries()
}
.show()
}
}
val dialog = builder.create()
dialog.show()
// make links in the HTML text work
(dialog.findViewById<View>(android.R.id.message) as TextView).movementMethod =
LinkMovementMethod.getInstance()
}
// copied from CustomInputStyleSettingsFragment
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.add_style, menu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val value = TypedValue()
activity.theme.resolveAttribute(android.R.attr.colorForeground, value, true)
menu.findItem(R.id.action_add_style).icon?.setTint(value.data)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val itemId = item.itemId
if (itemId == R.id.action_add_style) {
currentDictLocale = null
currentDictState = DICT_NEW
showUpdateDialog()
}
return super.onOptionsItemSelected(item)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (requestCode == DICTIONARY_REQUEST_CODE) onDictionaryFileSelected(resultCode, resultData)
}
private fun onDictionaryFileSelected(resultCode: Int, resultData: Intent?) {
if (resultCode != Activity.RESULT_OK || resultData == null) {
onDictionaryLoadingError(R.string.dictionary_load_error.resString())
return
}
val uri = resultData.data ?: return onDictionaryLoadingError(R.string.dictionary_load_error.resString())
cachedDictionaryFile.delete()
try {
FileUtils.copyStreamToNewFile(
activity.contentResolver.openInputStream(uri),
cachedDictionaryFile
)
} catch (e: IOException) {
onDictionaryLoadingError(R.string.dictionary_load_error.resString())
return
}
val newHeader = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(cachedDictionaryFile, 0, cachedDictionaryFile.length())
if (newHeader == null) {
cachedDictionaryFile.delete()
onDictionaryLoadingError(R.string.dictionary_file_error.resString())
return
}
val locale = newHeader.mLocaleString.toLocale()
if (currentDictLocale != null && locale != currentDictLocale) {
cachedDictionaryFile.delete()
onDictionaryLoadingError(resources.getString(R.string.dictionary_file_wrong_locale, locale.displayName(), currentDictLocale?.displayName()))
return
}
// idString is content of 'dictionary' key, in format <type>:<locale>
val dictionaryType = newHeader.mIdString.substringBefore(":")
val userDictFile = File(locale.getUserDictFilename(dictionaryType))
// ask for user confirmation if it would be a version downgrade or if user pressed add new,
// but we already have a user dictionary for the same locale
val shouldAskMessageId = if (userDictFile.exists()) {
val oldHeader = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(userDictFile, 0, userDictFile.length())
if (oldHeader != null && oldHeader.mVersionString.toInt() > newHeader.mVersionString.toInt())
R.string.overwrite_old_dicitonary_messsage
else if (currentDictState == DICT_NEW && currentDictLocale == null)
R.string.replace_dictionary_message
else 0
} else 0
if (shouldAskMessageId != 0)
showConfirmReplaceDialog(locale, dictionaryType, shouldAskMessageId)
else
moveCachedFileToDictionaries(locale, dictionaryType)
}
private fun showConfirmReplaceDialog(locale: Locale, dictionaryType: String, messageId: Int) {
AlertDialog.Builder(DialogUtils.getPlatformDialogThemeContext(activity))
.setTitle(R.string.replace_dictionary)
.setMessage(resources.getString(messageId, locale.displayName()))
.setCancelable(false)
.setNegativeButton(R.string.cancel, ) { _,_ ->
cachedDictionaryFile.delete()
}
.setPositiveButton(R.string.replace_dictionary) { _,_ ->
moveCachedFileToDictionaries(locale, dictionaryType)
}
.show()
}
private fun moveCachedFileToDictionaries(locale: Locale, dictionaryType: String) {
val dictFile = File(locale.getUserDictFilename(dictionaryType))
if (!cachedDictionaryFile.renameTo(dictFile)) {
cachedDictionaryFile.delete()
onDictionaryLoadingError(R.string.dictionary_load_error.resString())
return
}
// success, now remove internal dictionary file if a main dictionary was added
if (dictionaryType == DictionaryInfoUtils.DEFAULT_MAIN_DICT)
File(locale.getInternalDictFilename()).delete()
// inform user about success
val successMessageForLocale = resources
.getString(R.string.dictionary_load_success, locale.displayName())
Toast.makeText(activity, successMessageForLocale, Toast.LENGTH_LONG).show()
// inform LatinIME about new dictionary
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
activity.sendBroadcast(newDictBroadcast)
reloadDictionaries()
}
private fun onDictionaryLoadingError(message: String) {
AlertDialog.Builder(DialogUtils.getPlatformDialogThemeContext(activity))
.setNegativeButton(android.R.string.ok, null)
.setMessage(message)
.setTitle("loading error")
.show()
}
private fun Locale.getUserDictFilename(dictionaryType: String) =
DictionaryInfoUtils.getCacheDirectoryForLocale(this.toString(), activity) + File.separator + dictionaryType + "_" + USER_DICTIONARY_SUFFIX
private fun Locale.getUserDictFilenames(): List<String> {
val dicts = mutableListOf<String>()
val p = DictionaryInfoUtils.getCacheDirectoryForLocale(this.toString(), activity)
DictionaryInfoUtils.getCachedDirectoryList(activity)?.forEach { dir ->
if (!dir.isDirectory)
return@forEach
dir.list()?.forEach {
if (it.endsWith(USER_DICTIONARY_SUFFIX))
dicts.add(p + File.separator + it)
}
}
return dicts
}
private fun Locale.getInternalDictFilename() =
DictionaryInfoUtils.getCacheDirectoryForLocale(this.toString(), activity) + File.separator + DictionaryInfoUtils.getMainDictFilename(this.toString())
private fun String.displayName() = LocaleUtils.constructLocaleFromString(this).displayName()
private fun String.toLocale() = LocaleUtils.constructLocaleFromString(this)
private fun Locale.displayName() = getDisplayName(resources.configuration.locale)
private fun Int.resString() = resources.getString(this)
companion object {
private const val DICTIONARY_REQUEST_CODE = 96834
private const val DICTIONARY_URL =
"https://github.com/Helium314/openboard/dictionaries/dict"
private const val USER_DICTIONARY_SUFFIX = "user.dict"
private const val DICT_INTERNAL_AND_USER = 2
private const val DICT_INTERNAL_ONLY = 1
private const val DICT_USER_ONLY = 0
private const val DICT_NEW = -1
}
}

View file

@ -65,6 +65,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_VOICE_INPUT_KEY = "pref_voice_input_key";
public static final String PREF_CLIPBOARD_CLIPBOARD_KEY = "pref_clipboard_clipboard_key";
public static final String PREF_EDIT_PERSONAL_DICTIONARY = "edit_personal_dictionary";
public static final String PREF_ADD_DICTIONARY = "add_dictionary";
public static final String PREF_AUTO_CORRECTION = "pref_key_auto_correction";
public static final String PREF_AUTO_CORRECTION_CONFIDENCE = "pref_key_auto_correction_confidence";
// PREF_SHOW_SUGGESTIONS_SETTING_OBSOLETE is obsolete. Use PREF_SHOW_SUGGESTIONS instead.

View file

@ -52,8 +52,9 @@ import javax.annotation.Nullable;
public class DictionaryInfoUtils {
private static final String TAG = DictionaryInfoUtils.class.getSimpleName();
public static final String RESOURCE_PACKAGE_NAME = R.class.getPackage().getName();
private static final String DEFAULT_MAIN_DICT = "main";
private static final String MAIN_DICT_PREFIX = "main_";
public static final String DEFAULT_MAIN_DICT = "main";
public static final String MAIN_DICT_PREFIX = DEFAULT_MAIN_DICT + "_";
private static final String DICTIONARY_CATEGORY_SEPARATOR_EXPRESSION = "[" + BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR + "_]";
private static final String DECODER_DICT_SUFFIX = DecoderSpecificConstants.DECODER_DICT_SUFFIX;
// 6 digits - unicode is limited to 21 bits
private static final int MAX_HEX_DIGITS_FOR_CODEPOINT = 6;
@ -151,7 +152,7 @@ public class DictionaryInfoUtils {
/**
* Helper method to get the top level cache directory.
*/
private static String getWordListCacheDirectory(final Context context) {
public static String getWordListCacheDirectory(final Context context) {
return context.getFilesDir() + File.separator + "dicts";
}
@ -212,10 +213,12 @@ public class DictionaryInfoUtils {
@Nullable
public static String getCategoryFromFileName(@Nonnull final String fileName) {
final String id = getWordListIdFromFileName(fileName);
final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
final String[] idArray = id.split(DICTIONARY_CATEGORY_SEPARATOR_EXPRESSION);
// An id is supposed to be in format category:locale, so splitting on the separator
// should yield a 2-elements array
if (2 != idArray.length) {
// Also allow '_' as separator, this is ok for locales like pt_br because
// we're interested in the part before first separator anyway
if (1 == idArray.length) {
return null;
}
return idArray[0];
@ -225,7 +228,7 @@ public class DictionaryInfoUtils {
* Find out the cache directory associated with a specific locale.
*/
public static String getCacheDirectoryForLocale(final String locale, final Context context) {
final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale);
final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale).toLowerCase(Locale.ENGLISH);
final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator
+ relativeDirectoryName;
final File directory = new File(absoluteDirectoryName);
@ -238,10 +241,12 @@ public class DictionaryInfoUtils {
}
public static boolean isMainWordListId(final String id) {
final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
final String[] idArray = id.split(DICTIONARY_CATEGORY_SEPARATOR_EXPRESSION);
// An id is supposed to be in format category:locale, so splitting on the separator
// should yield a 2-elements array
if (2 != idArray.length) {
// Also allow '_' as separator, this is ok for locales like pt_br because
// we're interested in the part before first separator anyway
if (1 == idArray.length) {
return false;
}
return BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY.equals(idArray[0]);
@ -318,6 +323,10 @@ public class DictionaryInfoUtils {
BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR + locale.toString().toLowerCase();
}
public static String getMainDictFilename(@Nonnull final String locale) {
return MAIN_DICT_PREFIX + locale.toLowerCase(Locale.ENGLISH) + ".dict";
}
public static DictionaryHeader getDictionaryFileHeaderOrNull(final File file,
final long offset, final long length) {
try {