1
0
Fork 0

Objectbox (#400)

* replaced SQLite/RoomDb with Objectbox for improved dictionary performance

* SQLite words are automatically cleaned up when opening the Preferences

* added protection against deleting dictionaries while loading other dictionaries

* enabled adding words with apostrophes in Ukrainian

* updated system requirements
This commit is contained in:
Dimo Karaivanov 2023-12-21 14:30:27 +02:00 committed by GitHub
parent 7fb1ca7b5b
commit c02b4149e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 693 additions and 691 deletions

View file

@ -18,8 +18,8 @@ or get the APK from the [Releases Section](https://github.com/sspanak/tt9/releas
## System Requirements ## System Requirements
- Android 4.4 or higher. _(Tested and confirmed on Android 4.4.2, 10 and 11)_ - Android 4.4 or higher. _(Tested and confirmed on Android 4.4.2, 10 and 11)_
- Free space: - Free space:
- Minimum 30 Mb when not using Predictive mode and no dictionaries are loaded. - Minimum 40 Mb when not using Predictive mode and no dictionaries are loaded.
- Plenty of space per each enabled language in Predictive mode (25-100 Mb, depending on the word count). - Plenty of space per each enabled language in Predictive mode (50-400 Mb, depending on the word count).
- A hardware keypad or a keyboard. For touchscreen-only devices, an on-screen keypad can be enabled in the Settings. - A hardware keypad or a keyboard. For touchscreen-only devices, an on-screen keypad can be enabled in the Settings.
_If you own a phone with Android 2.2 up to 4.4, please refer to the original version of Traditional T9 from 2016._ _If you own a phone with Android 2.2 up to 4.4, please refer to the original version of Traditional T9 from 2016._

View file

@ -9,10 +9,13 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.0.2' classpath 'com.android.tools.build:gradle:8.0.2'
classpath 'gradle.plugin.at.zierler:yaml-validator-plugin:1.5.0' classpath 'gradle.plugin.at.zierler:yaml-validator-plugin:1.5.0'
classpath("io.objectbox:objectbox-gradle-plugin:3.7.1")
} }
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'at.zierler.yamlvalidator' apply plugin: 'at.zierler.yamlvalidator'
apply plugin: "io.objectbox"
apply from: 'gradle/scripts/constants.gradle' apply from: 'gradle/scripts/constants.gradle'
apply from: 'gradle/scripts/dictionary-tools.gradle' apply from: 'gradle/scripts/dictionary-tools.gradle'
@ -20,10 +23,6 @@ apply from: 'gradle/scripts/validate-languages.gradle'
apply from: 'gradle/scripts/version-tools.gradle' apply from: 'gradle/scripts/version-tools.gradle'
configurations.configureEach { configurations.configureEach {
// fixes 'duplicate class error', when using these combine: androidx.core:1.10.1, androidx.preference:1.2.0 and androidx.room:2.5.1
// see: https://stackoverflow.com/questions/75274720/a-failure-occurred-while-executing-appcheckdebugduplicateclasses/75315276#75315276
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8'
yamlValidator { yamlValidator {
searchPaths = ['languages/definitions'] searchPaths = ['languages/definitions']
} }
@ -32,8 +31,10 @@ configurations.configureEach {
dependencies { dependencies {
implementation 'androidx.core:core:1.10.1' implementation 'androidx.core:core:1.10.1'
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.room:room-runtime:2.5.2'
annotationProcessor 'androidx.room:room-compiler:2.5.2' // fixes 'duplicate class error' when using "androidx.core" > 1.9.0
// see: https://stackoverflow.com/a/77323424
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
} }
repositories { repositories {

View file

@ -0,0 +1,78 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:8686759156275895115",
"lastPropertyId": "9:148562041880145406",
"name": "Word",
"properties": [
{
"id": "1:7053654361616810150",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:1100881396300803213",
"name": "frequency",
"type": 5
},
{
"id": "3:3482550679443532896",
"name": "isCustom",
"type": 1
},
{
"id": "4:7366572918924354162",
"name": "langId",
"type": 5
},
{
"id": "5:2610279871343053806",
"name": "length",
"type": 5
},
{
"id": "6:5269773217039117329",
"name": "sequence",
"indexId": "1:2971223841434624317",
"type": 9,
"flags": 2048
},
{
"id": "7:3922044271904033267",
"name": "sequenceShort",
"indexId": "2:2641086768976362614",
"type": 2,
"flags": 8
},
{
"id": "8:1684236207225806285",
"name": "uniqueId",
"indexId": "3:5820769207826940948",
"type": 9,
"flags": 34848
},
{
"id": "9:148562041880145406",
"name": "word",
"type": 9
}
],
"relations": []
}
],
"lastEntityId": "1:8686759156275895115",
"lastIndexId": "3:5820769207826940948",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}

View file

@ -1,11 +1,9 @@
package io.github.sspanak.tt9.db; package io.github.sspanak.tt9.db;
import android.content.Context; import android.content.Context;
import android.database.sqlite.SQLiteConstraintException;
import android.os.Handler; import android.os.Handler;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.sqlite.db.SimpleSQLiteQuery;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -13,24 +11,22 @@ import java.util.List;
import io.github.sspanak.tt9.ConsumerCompat; import io.github.sspanak.tt9.ConsumerCompat;
import io.github.sspanak.tt9.Logger; import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.exceptions.InsertBlankWordException; import io.github.sspanak.tt9.db.exceptions.InsertBlankWordException;
import io.github.sspanak.tt9.db.room.TT9Room; import io.github.sspanak.tt9.db.objectbox.Word;
import io.github.sspanak.tt9.db.room.Word; import io.github.sspanak.tt9.db.objectbox.WordList;
import io.github.sspanak.tt9.db.room.WordList; import io.github.sspanak.tt9.db.objectbox.WordStore;
import io.github.sspanak.tt9.db.room.WordsDao;
import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.InvalidLanguageException;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.preferences.SettingsStore;
import io.objectbox.exception.UniqueViolationException;
public class DictionaryDb { public class DictionaryDb {
private static TT9Room dbInstance; private static WordStore store;
private static final Handler asyncHandler = new Handler(); private static final Handler asyncHandler = new Handler();
public static synchronized void init(Context context) { public static synchronized void init(Context context) {
if (dbInstance == null) { if (store == null) {
context = context == null ? TraditionalT9.getMainContext() : context; context = context == null ? TraditionalT9.getMainContext() : context;
dbInstance = TT9Room.getInstance(context); store = new WordStore(context);
} }
} }
@ -40,18 +36,18 @@ public class DictionaryDb {
} }
private static TT9Room getInstance() { private static WordStore getStore() {
init(); init();
return dbInstance; return store;
} }
private static void printDebug(String tag, String title, String sequence, WordList words, long startTime) { private static void printLoadDebug(String sequence, WordList words, long startTime) {
if (!Logger.isDebugLevel()) { if (!Logger.isDebugLevel()) {
return; return;
} }
StringBuilder debugText = new StringBuilder(title); StringBuilder debugText = new StringBuilder("===== Word Matches =====");
debugText debugText
.append("\n") .append("\n")
.append("Word Count: ").append(words.size()) .append("Word Count: ").append(words.size())
@ -62,12 +58,12 @@ public class DictionaryDb {
debugText.append(" Sequence: ").append(sequence); debugText.append(" Sequence: ").append(sequence);
} }
Logger.d(tag, debugText.toString()); Logger.d("loadWords", debugText.toString());
} }
public static void runInTransaction(Runnable r) { public static void runInTransaction(Runnable r) {
getInstance().runInTransaction(r); getStore().runInTransaction(r);
} }
@ -78,42 +74,66 @@ public class DictionaryDb {
* This query will finish immediately, if there is nothing to do. It's safe to run it often. * This query will finish immediately, if there is nothing to do. It's safe to run it often.
*/ */
public static void normalizeWordFrequencies(SettingsStore settings) { public static void normalizeWordFrequencies(SettingsStore settings) {
final String LOG_TAG = "db.normalizeWordFrequencies";
new Thread(() -> { new Thread(() -> {
long time = System.currentTimeMillis(); for (int langId : getStore().getLanguages()) {
getStore().runInTransactionAsync(() -> {
try {
long start = System.currentTimeMillis();
int affectedRows = getInstance().wordsDao().normalizeFrequencies( if (getStore().getMaxFrequency(langId) < settings.getWordFrequencyMax()) {
settings.getWordFrequencyNormalizationDivider(), return;
settings.getWordFrequencyMax() }
);
Logger.d( List<Word> words = getStore().getMany(langId);
"db.normalizeWordFrequencies", if (words == null) {
"Normalized " + affectedRows + " words in: " + (System.currentTimeMillis() - time) + " ms" return;
); }
for (Word w : words) {
w.frequency /= settings.getWordFrequencyNormalizationDivider();
}
getStore().put(words);
Logger.d(
LOG_TAG,
"Normalized language: " + langId + ", " + words.size() + " words in: " + (System.currentTimeMillis() - start) + " ms"
);
} catch (Exception e) {
Logger.e(LOG_TAG, "Word normalization failed. " + e.getMessage());
} finally {
getStore().closeThreadResources();
}
});
}
}).start(); }).start();
} }
public static void areThereWords(ConsumerCompat<Boolean> notification, Language language) { public static void areThereWords(ConsumerCompat<Boolean> notification, Language language) {
new Thread(() -> { new Thread(() -> {
int langId = language != null ? language.getId() : -1; boolean areThere = getStore().count(language != null ? language.getId() : -1) > 0;
notification.accept(getInstance().wordsDao().count(langId) > 0); getStore().closeThreadResources();
notification.accept(areThere);
}).start(); }).start();
} }
public static void deleteWords(Runnable notification) { public static void deleteWords(Context context, Runnable notification) {
deleteWords(notification, null); new Thread(() -> {
getStore().destroy();
store = null;
init(context);
notification.run();
}).start();
} }
public static void deleteWords(Runnable notification, ArrayList<Integer> languageIds) { public static void deleteWords(Runnable notification, @NonNull ArrayList<Integer> languageIds) {
new Thread(() -> { new Thread(() -> {
if (languageIds == null) { getStore().removeMany(languageIds).closeThreadResources();
getInstance().clearAllTables();
} else if (languageIds.size() > 0) {
getInstance().wordsDao().deleteByLanguage(languageIds);
}
notification.run(); notification.run();
}).start(); }).start();
} }
@ -124,168 +144,128 @@ public class DictionaryDb {
throw new InsertBlankWordException(); throw new InsertBlankWordException();
} }
Word dbWord = new Word();
dbWord.langId = language.getId();
dbWord.sequence = language.getDigitSequenceForWord(word);
dbWord.word = word;
dbWord.length = word.length();
dbWord.frequency = 1;
new Thread(() -> { new Thread(() -> {
try { try {
if (getInstance().wordsDao().doesWordExist(dbWord.langId, dbWord.word) > 0) { if (getStore().exists(language.getId(), word, language.getDigitSequenceForWord(word))) {
throw new SQLiteConstraintException("Word already exists."); throw new UniqueViolationException("Word already exists");
} }
getStore().put(Word.create(language, word, 1, true));
getInstance().wordsDao().insert(dbWord);
getInstance().wordsDao().incrementFrequency(dbWord.langId, dbWord.word, dbWord.sequence);
statusHandler.accept(0); statusHandler.accept(0);
} catch (SQLiteConstraintException e) { } catch (UniqueViolationException e) {
String msg = "Constraint violation when inserting a word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId String msg = "Skipping word: '" + word + "' for language: " + language.getId() + ", because it already exists.";
+ ". " + e.getMessage(); Logger.w("insertWord", msg);
Logger.e("insertWord", msg);
statusHandler.accept(1); statusHandler.accept(1);
} catch (Exception e) { } catch (Exception e) {
String msg = "Failed inserting word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId + ". " + e.getMessage(); String msg = "Failed inserting word: '" + word + "' for language: " + language.getId() + ". " + e.getMessage();
Logger.e("insertWord", msg); Logger.e("insertWord", msg);
statusHandler.accept(2); statusHandler.accept(2);
} finally {
getStore().closeThreadResources();
} }
}).start(); }).start();
} }
public static void upsertWordsSync(List<Word> words) { public static void upsertWordsSync(List<Word> words) {
getInstance().wordsDao().upsertMany(words); getStore().put(words);
getStore().closeThreadResources();
} }
public static void incrementWordFrequency(Language language, String word, String sequence) throws Exception { public static void incrementWordFrequency(@NonNull Language language, @NonNull String word, @NonNull String sequence) {
Logger.d("incrementWordFrequency", "Incrementing priority of Word: " + word +" | Sequence: " + sequence); // If any of these is empty, it is the same as changing the frequency of: "", which is simply a no-op.
if (word.length() == 0 || sequence.length() == 0) {
if (language == null) {
throw new InvalidLanguageException();
}
// If both are empty, it is the same as changing the frequency of: "", which is simply a no-op.
if ((word == null || word.length() == 0) && (sequence == null || sequence.length() == 0)) {
return; return;
} }
// If one of them is empty, then this is an invalid operation,
// because a digit sequence exist for every word.
if (word == null || word.length() == 0 || sequence == null || sequence.length() == 0) {
throw new Exception("Cannot increment word frequency. Word: " + word + " | Sequence: " + sequence);
}
new Thread(() -> { new Thread(() -> {
try { try {
int affectedRows = getInstance().wordsDao().incrementFrequency(language.getId(), word, sequence); long start = System.currentTimeMillis();
Word dbWord = getStore().get(language.getId(), word, sequence);
// In case the user has changed the text case, there would be no match. // In case the user has changed the text case, there would be no match.
// Try again with the lowercase equivalent. // Try again with the lowercase equivalent.
if (affectedRows == 0) { if (dbWord == null) {
String lowercaseWord = word.toLowerCase(language.getLocale()); dbWord = getStore().get(language.getId(), word.toLowerCase(language.getLocale()), sequence);
affectedRows = getInstance().wordsDao().incrementFrequency(language.getId(), lowercaseWord, sequence);
Logger.d("incrementWordFrequency", "Attempting to increment frequency for lowercase variant: " + lowercaseWord);
} }
Logger.d("incrementWordFrequency", "Affected rows: " + affectedRows); if (dbWord == null) {
throw new Exception("No such word");
}
int max = getStore().getMaxFrequency(dbWord.langId, dbWord.sequence, dbWord.word);
if (dbWord.frequency <= max) {
dbWord.frequency = max + 1;
getStore().put(dbWord);
long time = System.currentTimeMillis() - start;
Logger.d(
"incrementWordFrequency",
"Incremented frequency of '" + dbWord.word + "' to: " + dbWord.frequency + ". Time: " + time + " ms"
);
} else {
long time = System.currentTimeMillis() - start;
Logger.d(
"incrementWordFrequency",
"'" + dbWord.word + "' is already the top word. Keeping frequency: " + dbWord.frequency + ". Time: " + time + " ms"
);
}
} catch (Exception e) { } catch (Exception e) {
Logger.e( Logger.e(
DictionaryDb.class.getName(), DictionaryDb.class.getName(),
"Failed incrementing word frequency. Word: " + word + " | Sequence: " + sequence + ". " + e.getMessage() "Failed incrementing word frequency. Word: " + word + ". " + e.getMessage()
); );
} finally {
getStore().closeThreadResources();
} }
}).start(); }).start();
} }
/** /**
* loadWordsExact * loadWords
* Loads words that match exactly the "sequence" and the optional "filter". * Loads words matching and similar to a given digit sequence
* For example: "7655" gets "roll". * For example: "7655" -> "roll" (exact match), but also: "rolled", "roller", "rolling", ...
* and other similar.
*/ */
private static ArrayList<String> loadWordsExact(Language language, String sequence, String filter, int maximumWords) { private static ArrayList<String> loadWords(Language language, String sequence, String filter, int minimumWords, int maximumWords) {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
WordList matches = new WordList(getInstance().wordsDao().getMany(
language.getId(),
maximumWords,
sequence,
filter == null || filter.equals("") ? null : filter
));
printDebug("loadWordsExact", "===== Exact Word Matches =====", sequence, matches, start); WordList matches = getStore()
.getMany(language, sequence, filter, maximumWords)
.filter(sequence.length(), minimumWords);
getStore().closeThreadResources();
printLoadDebug(sequence, matches, start);
return matches.toStringList(); return matches.toStringList();
} }
/** public static void getWords(ConsumerCompat<ArrayList<String>> dataHandler, Language language, String sequence, String filter, int minimumWords, int maximumWords) {
* loadWordsFuzzy final int minWords = Math.max(minimumWords, 0);
* Loads words that start with "sequence" and optionally match the "filter". final int maxWords = Math.max(maximumWords, minWords);
* For example: "7655" -> "roll", but also: "rolled", "roller", "rolling", ...
*/
private static ArrayList<String> loadWordsFuzzy(Language language, String sequence, String filter, int maximumWords) {
long start = System.currentTimeMillis();
// fuzzy queries are heavy, so we must restrict the search range as much as possible if (sequence == null || sequence.length() == 0) {
boolean noFilter = (filter == null || filter.equals("")); Logger.w("db.getWords", "Attempting to get words for an empty sequence.");
int maxWordLength = noFilter && sequence.length() <= 2 ? 5 : 1000; sendWords(dataHandler, new ArrayList<>());
String index = sequence.length() <= 2 ? WordsDao.indexShortWords : WordsDao.indexLongWords; return;
SimpleSQLiteQuery sql = TT9Room.getFuzzyQuery(index, language.getId(), maximumWords, sequence, sequence.length(), maxWordLength, filter);
WordList matches = new WordList(getInstance().wordsDao().getCustom(sql));
// In some cases, searching for words starting with "digitSequence" and limited to "maxWordLength" of 5,
// may yield too few results. If so, we expand the search range a bit.
if (noFilter && matches.size() < maximumWords) {
sql = TT9Room.getFuzzyQuery(
WordsDao.indexLongWords,
language.getId(),
maximumWords - matches.size(),
sequence,
5,
1000
);
matches.addAll(getInstance().wordsDao().getCustom(sql));
} }
printDebug("loadWordsFuzzy", "~=~=~=~ Fuzzy Word Matches ~=~=~=~", sequence, matches, start); if (language == null) {
return matches.toStringList(); Logger.w("db.getWords", "Attempting to get words for NULL language.");
sendWords(dataHandler, new ArrayList<>());
return;
}
new Thread(() -> sendWords(
dataHandler,
loadWords(language, sequence, filter, minWords, maxWords))
).start();
} }
private static void sendWords(ConsumerCompat<ArrayList<String>> dataHandler, ArrayList<String> wordList) { private static void sendWords(ConsumerCompat<ArrayList<String>> dataHandler, ArrayList<String> wordList) {
asyncHandler.post(() -> dataHandler.accept(wordList)); asyncHandler.post(() -> dataHandler.accept(wordList));
} }
public static void getWords(ConsumerCompat<ArrayList<String>> dataHandler, Language language, String sequence, String filter, int minimumWords, int maximumWords) {
final int minWords = Math.max(minimumWords, 0);
final int maxWords = Math.max(maximumWords, minimumWords);
ArrayList<String> wordList = new ArrayList<>(maxWords);
if (sequence == null || sequence.length() == 0) {
Logger.w("db.getWords", "Attempting to get words for an empty sequence.");
sendWords(dataHandler, wordList);
return;
}
if (language == null) {
Logger.w("db.getWords", "Attempting to get words for NULL language.");
sendWords(dataHandler, wordList);
return;
}
new Thread(() -> {
wordList.addAll(loadWordsExact(language, sequence, filter, maxWords));
if (sequence.length() > 1 && wordList.size() < minWords) {
wordList.addAll(loadWordsFuzzy(language, sequence, filter, minWords - wordList.size()));
}
sendWords(dataHandler, wordList);
}).start();
}
} }

View file

@ -16,7 +16,7 @@ import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException; import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAlreadyRunningException; import io.github.sspanak.tt9.db.exceptions.DictionaryImportAlreadyRunningException;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportException; import io.github.sspanak.tt9.db.exceptions.DictionaryImportException;
import io.github.sspanak.tt9.db.room.Word; import io.github.sspanak.tt9.db.objectbox.Word;
import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException; import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.InvalidLanguageException; import io.github.sspanak.tt9.languages.InvalidLanguageException;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
@ -80,7 +80,7 @@ public class DictionaryLoader {
currentFile = 0; currentFile = 0;
importStartTime = System.currentTimeMillis(); importStartTime = System.currentTimeMillis();
sendFileCount(languages.size()); sendStartMessage(languages.size());
// SQLite does not support parallel queries, so let's import them one by one // SQLite does not support parallel queries, so let's import them one by one
for (Language lang : languages) { for (Language lang : languages) {
@ -155,8 +155,8 @@ public class DictionaryLoader {
Logger.e( Logger.e(
logTag, logTag,
"Failed loading dictionary: " + language.getDictionaryFile() + "Failed loading dictionary: " + language.getDictionaryFile()
" for language '" + language.getName() + "'. " + " for language '" + language.getName() + "'. "
+ e.getMessage() + e.getMessage()
); );
} }
@ -164,7 +164,7 @@ public class DictionaryLoader {
} }
private void importLetters(Language language) { private void importLetters(Language language) throws InvalidLanguageCharactersException {
ArrayList<Word> letters = new ArrayList<>(); ArrayList<Word> letters = new ArrayList<>();
boolean isEnglish = language.getLocale().equals(Locale.ENGLISH); boolean isEnglish = language.getLocale().equals(Locale.ENGLISH);
@ -172,15 +172,7 @@ public class DictionaryLoader {
for (int key = 2; key <= 9; key++) { for (int key = 2; key <= 9; key++) {
for (String langChar : language.getKeyCharacters(key, false)) { for (String langChar : language.getKeyCharacters(key, false)) {
langChar = (isEnglish && langChar.equals("i")) ? langChar.toUpperCase(Locale.ENGLISH) : langChar; langChar = (isEnglish && langChar.equals("i")) ? langChar.toUpperCase(Locale.ENGLISH) : langChar;
letters.add(Word.create(language, langChar, 0));
Word word = new Word();
word.langId = language.getId();
word.frequency = 0;
word.length = 1;
word.sequence = String.valueOf(key);
word.word = langChar;
letters.add(word);
} }
} }
@ -189,7 +181,7 @@ public class DictionaryLoader {
private void importWords(Language language, String dictionaryFile) throws Exception { private void importWords(Language language, String dictionaryFile) throws Exception {
sendProgressMessage(language, 0, 0); sendProgressMessage(language, 1, 0);
long currentLine = 0; long currentLine = 0;
long totalLines = getFileSize(dictionaryFile); long totalLines = getFileSize(dictionaryFile);
@ -200,7 +192,7 @@ public class DictionaryLoader {
for (String line; (line = br.readLine()) != null; currentLine++) { for (String line; (line = br.readLine()) != null; currentLine++) {
if (loadThread.isInterrupted()) { if (loadThread.isInterrupted()) {
br.close(); br.close();
sendProgressMessage(language, -1, 0); sendProgressMessage(language, 0, 0);
throw new DictionaryImportAbortedException(); throw new DictionaryImportAbortedException();
} }
@ -209,7 +201,7 @@ public class DictionaryLoader {
int frequency = getFrequency(parts); int frequency = getFrequency(parts);
try { try {
dbWords.add(stringToWord(language, word, frequency)); dbWords.add(Word.create(language, word, frequency));
} catch (InvalidLanguageCharactersException e) { } catch (InvalidLanguageCharactersException e) {
br.close(); br.close();
throw new DictionaryImportException(word, currentLine); throw new DictionaryImportException(word, currentLine);
@ -222,6 +214,7 @@ public class DictionaryLoader {
if (totalLines > 0) { if (totalLines > 0) {
int progress = (int) Math.floor(100.0 * currentLine / totalLines); int progress = (int) Math.floor(100.0 * currentLine / totalLines);
progress = Math.max(1, progress);
sendProgressMessage(language, progress, settings.getDictionaryImportProgressUpdateInterval()); sendProgressMessage(language, progress, settings.getDictionaryImportProgressUpdateInterval());
} }
} }
@ -269,19 +262,7 @@ public class DictionaryLoader {
} }
private Word stringToWord(Language language, String word, int frequency) throws InvalidLanguageCharactersException { private void sendStartMessage(int fileCount) {
Word dbWord = new Word();
dbWord.langId = language.getId();
dbWord.frequency = frequency;
dbWord.length = word.length();
dbWord.sequence = language.getDigitSequenceForWord(word);
dbWord.word = word;
return dbWord;
}
private void sendFileCount(int fileCount) {
if (onStatusChange == null) { if (onStatusChange == null) {
Logger.w(logTag, "Cannot send file count without a status Handler. Ignoring message."); Logger.w(logTag, "Cannot send file count without a status Handler. Ignoring message.");
return; return;
@ -289,6 +270,7 @@ public class DictionaryLoader {
Bundle progressMsg = new Bundle(); Bundle progressMsg = new Bundle();
progressMsg.putInt("fileCount", fileCount); progressMsg.putInt("fileCount", fileCount);
progressMsg.putInt("progress", 1);
asyncHandler.post(() -> onStatusChange.accept(progressMsg)); asyncHandler.post(() -> onStatusChange.accept(progressMsg));
} }

View file

@ -0,0 +1,78 @@
package io.github.sspanak.tt9.db;
import android.app.Activity;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import java.io.File;
import io.github.sspanak.tt9.Logger;
public class SQLWords {
private static final String DB_NAME = "t9dict.db";
private static final String TABLE_NAME = "words";
private static boolean isCompleted = false;
private final String LOG_TAG;
private final Activity activity;
private SQLiteDatabase db;
public SQLWords(Activity activity) {
this.activity = activity;
LOG_TAG = getClass().getSimpleName();
}
public void clear() {
if (isCompleted) {
return;
}
new Thread(() -> {
openDb();
if (areThereWords()) {
deleteAll();
}
closeDb();
isCompleted = true;
}).start();
}
private void openDb() {
try {
db = null;
File dbFile = activity.getDatabasePath(DB_NAME);
if (dbFile.exists()) {
db = SQLiteDatabase.openDatabase(dbFile.getAbsolutePath(), null, SQLiteDatabase.OPEN_READWRITE);
}
} catch (Exception e) {
Logger.d(LOG_TAG, "Assuming no SQL database, because of error while opening. " + e.getMessage());
db = null;
}
}
private void closeDb() {
if (db != null) {
db.close();
}
}
private boolean areThereWords() {
String sql = "SELECT COUNT(*) FROM (SELECT id FROM " + TABLE_NAME + " LIMIT 1)";
try (Cursor cursor = db.rawQuery(sql, null)) {
return cursor.moveToFirst() && cursor.getInt(0) > 0;
} catch (Exception e) {
Logger.d(LOG_TAG, "Assuming no words, because of query error. " + e.getMessage());
return false;
}
}
private void deleteAll() {
db.execSQL("DROP TABLE words");
Logger.d(LOG_TAG, "SQL Words cleaned successfully.");
}
}

View file

@ -1,26 +0,0 @@
package io.github.sspanak.tt9.db.migrations;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.room.TT9Room;
public class DB10 {
public static final Migration MIGRATION = new Migration(9, 10) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
try {
database.beginTransaction();
database.execSQL(TT9Room.createShortWordsIndexQuery().getSql());
database.execSQL(TT9Room.createLongWordsIndexQuery().getSql());
database.setTransactionSuccessful();
} catch (Exception e) {
Logger.e("Migrate to DB10", "Migration failed. " + e.getMessage());
} finally {
database.endTransaction();
}
}
};
}

File diff suppressed because one or more lines are too long

View file

@ -1,13 +0,0 @@
package io.github.sspanak.tt9.db.migrations;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
public class DB6 {
public static final Migration MIGRATION = new Migration(5, 6) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("DROP TRIGGER IF EXISTS normalize_freq");
}
};
}

View file

@ -1,90 +0,0 @@
package io.github.sspanak.tt9.db.migrations;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import java.util.ArrayList;
import java.util.Locale;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class DB7 {
private Context ctx;
public Migration getMigration(Context context) {
ctx = context;
return migration;
}
private int getNewLanguageId(int oldId) {
Language language;
switch (oldId) {
default:
return oldId;
case 1:
language = LanguageCollection.getByLocale(ctx, Locale.ENGLISH.toString());
break;
case 2:
language = LanguageCollection.getByLocale(ctx, "ru_RU");
break;
case 3:
language = LanguageCollection.getByLocale(ctx, Locale.GERMAN.toString());
break;
case 4:
language = LanguageCollection.getByLocale(ctx, Locale.FRENCH.toString());
break;
case 5:
language = LanguageCollection.getByLocale(ctx, Locale.ITALIAN.toString());
break;
case 6:
language = LanguageCollection.getByLocale(ctx, "uk_UA");
break;
case 7:
language = LanguageCollection.getByLocale(ctx, "bg_BG");
break;
case 8:
language = LanguageCollection.getByLocale(ctx, "nl_NL");
break;
case 9:
language = LanguageCollection.getByLocale(ctx, "es_ES");
break;
}
return language != null ? language.getId() : -1;
}
private final Migration migration = new Migration(6, 7) {
private void migrateSQL(SupportSQLiteDatabase database) {
for (int oldLangId = 1; oldLangId <= 9; oldLangId++) {
database.execSQL(
"UPDATE words " +
" SET lang = " + getNewLanguageId(oldLangId) +
" WHERE lang = " + oldLangId
);
}
}
private void migrateSettings() {
SettingsStore settings = new SettingsStore(ctx);
ArrayList<Integer> newLangIds = new ArrayList<>();
for (int langId : settings.getEnabledLanguageIds()) {
newLangIds.add(getNewLanguageId(langId));
}
settings.saveEnabledLanguageIds(newLangIds);
}
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
migrateSQL(database);
migrateSettings();
}
};
}

View file

@ -1,27 +0,0 @@
package io.github.sspanak.tt9.db.migrations;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.room.TT9Room;
public class DB8 {
public static final Migration MIGRATION = new Migration(7, 8) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
try {
database.beginTransaction();
database.execSQL("ALTER TABLE words ADD COLUMN len INTEGER NOT NULL DEFAULT 0");
database.execSQL("UPDATE words SET len = LENGTH(seq)");
database.execSQL(TT9Room.createShortWordsIndexQuery().getSql());
database.setTransactionSuccessful();
} catch (Exception e) {
Logger.e("Migrate to DB8", "Migration failed. " + e.getMessage());
} finally {
database.endTransaction();
}
}
};
}

View file

@ -1,27 +0,0 @@
package io.github.sspanak.tt9.db.migrations;
import androidx.annotation.NonNull;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.room.TT9Room;
import io.github.sspanak.tt9.db.room.WordsDao;
public class DB9 {
public static final Migration MIGRATION = new Migration(8, 9) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
try {
database.beginTransaction();
database.execSQL("DROP INDEX " + WordsDao.indexLongWords);
database.execSQL(TT9Room.createLongWordsIndexQuery().getSql());
database.setTransactionSuccessful();
} catch (Exception e) {
Logger.e("Migrate to DB9", "Migration failed. " + e.getMessage());
} finally {
database.endTransaction();
}
}
};
}

View file

@ -0,0 +1,48 @@
package io.github.sspanak.tt9.db.objectbox;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.Language;
import io.objectbox.annotation.ConflictStrategy;
import io.objectbox.annotation.Entity;
import io.objectbox.annotation.Id;
import io.objectbox.annotation.Index;
import io.objectbox.annotation.Unique;
@Entity
public class Word {
@Id public long id;
public int frequency;
public boolean isCustom;
public int langId;
public int length;
@Index public String sequence;
@Index public byte sequenceShort; // up to 2 digits
@Unique(onConflict = ConflictStrategy.REPLACE) public String uniqueId;
public String word;
public static Word create(@NonNull Language language, @NonNull String word, int frequency) throws InvalidLanguageCharactersException {
Word w = new Word();
w.frequency = frequency;
w.isCustom = false;
w.langId = language.getId();
w.length = word.length();
w.sequence = language.getDigitSequenceForWord(word);
w.sequenceShort = shrinkSequence(w.sequence);
w.uniqueId = (language.getId() + "-" + word);
w.word = word;
return w;
}
public static Word create(@NonNull Language language, @NonNull String word, int frequency, boolean isCustom) throws InvalidLanguageCharactersException {
Word w = create(language, word, frequency);
w.isCustom = isCustom;
return w;
}
public static Byte shrinkSequence(@NonNull String sequence) {
return Byte.parseByte(sequence.substring(0, Math.min(2, sequence.length())));
}
}

View file

@ -1,15 +1,22 @@
package io.github.sspanak.tt9.db.room; package io.github.sspanak.tt9.db.objectbox;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class WordList extends ArrayList<Word> { public class WordList extends ArrayList<Word> {
public WordList(List<Word> words) { public WordList() {
super();
}
public WordList(@NonNull List<Word> words) {
addAll(words); addAll(words);
} }
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {
@ -26,6 +33,19 @@ public class WordList extends ArrayList<Word> {
return sb.toString(); return sb.toString();
} }
@NonNull
public WordList filter(int minLength, int minWords) {
WordList filtered = new WordList();
for (int i = 0; i < size(); i++) {
if (get(i).length == minLength || filtered.size() < minWords) {
filtered.add(get(i));
}
}
return filtered;
}
@NonNull @NonNull
public ArrayList<String> toStringList() { public ArrayList<String> toStringList() {
ArrayList<String> strings = new ArrayList<>(); ArrayList<String> strings = new ArrayList<>();

View file

@ -0,0 +1,208 @@
package io.github.sspanak.tt9.db.objectbox;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.languages.Language;
import io.objectbox.Box;
import io.objectbox.BoxStore;
import io.objectbox.config.DebugFlags;
import io.objectbox.query.Query;
import io.objectbox.query.QueryBuilder;
import io.objectbox.query.QueryCondition;
public class WordStore {
private BoxStore boxStore;
private Box<Word> wordBox;
private Query<Word> longWordQuery;
private Query<Word> singleLetterQuery;
public WordStore(Context context) {
init(context);
}
private void init(Context context) {
boxStore = MyObjectBox.builder()
.androidContext(context.getApplicationContext())
.debugFlags(Logger.isDebugLevel() ? DebugFlags.LOG_QUERY_PARAMETERS : 0)
.build();
wordBox = boxStore.boxFor(Word.class);
longWordQuery = getLongWordQuery();
singleLetterQuery = getSingleLetterQuery();
}
private Query<Word> getLongWordQuery() {
return boxStore.boxFor(Word.class)
.query()
.equal(Word_.langId, 0)
.startsWith(Word_.sequence, "", QueryBuilder.StringOrder.CASE_SENSITIVE).parameterAlias("seq_start")
.lessOrEqual(Word_.sequence, "", QueryBuilder.StringOrder.CASE_SENSITIVE).parameterAlias("seq_end")
.equal(Word_.sequenceShort, 0)
.startsWith(Word_.word, "", QueryBuilder.StringOrder.CASE_SENSITIVE)
.order(Word_.length)
.orderDesc(Word_.frequency)
.build();
}
private Query<Word> getSingleLetterQuery() {
return wordBox
.query()
.equal(Word_.langId, 0)
.equal(Word_.sequenceShort, 0)
.startsWith(Word_.word, "", QueryBuilder.StringOrder.CASE_SENSITIVE)
.orderDesc(Word_.frequency)
.build();
}
public long count(int langId) {
try (Query<Word> query = wordBox.query(Word_.langId.equal(langId)).build()) {
return query.count();
} catch (Exception e) {
return 0;
}
}
public boolean exists(int langId, @NonNull String word, @NonNull String sequence) {
return get(langId, word, sequence) != null;
}
@Nullable
public Word get(int langId, @NonNull String word, @NonNull String sequence) {
QueryCondition<Word> where = Word_.langId.equal(langId)
.and(Word_.sequenceShort.equal(Word.shrinkSequence(sequence)))
.and(Word_.word.equal(word, QueryBuilder.StringOrder.CASE_SENSITIVE));
try (Query<Word> query = wordBox.query(where).build()) {
return query.findFirst();
}
}
@Nullable
public List<Word> getMany(int langId) {
try (Query<Word> query = wordBox.query(Word_.langId.equal(langId)).build()) {
return query.find();
}
}
@NonNull
public WordList getMany(Language language, @NonNull String sequence, @Nullable String filter, int maxWords) {
Query<Word> query;
if (sequence.length() < 2) {
singleLetterQuery.setParameter(Word_.sequenceShort, Byte.parseByte(sequence));
query = singleLetterQuery;
} else {
longWordQuery.setParameter(Word_.sequenceShort, Word.shrinkSequence(sequence));
longWordQuery.setParameter("seq_start", sequence);
longWordQuery.setParameter("seq_end", sequence + "99");
query = longWordQuery;
}
query.setParameter(Word_.langId, language.getId());
if (filter != null && !filter.equals("")) {
query.setParameter(Word_.word, filter);
} else {
query.setParameter(Word_.word, "");
}
return new WordList(query.find(0, maxWords));
}
public int[] getLanguages() {
try (Query<Word> query = wordBox.query().build()) {
return query.property(Word_.langId).distinct().findInts();
}
}
public int getMaxFrequency(int langId) {
return getMaxFrequency(langId, null, null);
}
public int getMaxFrequency(int langId, String sequence, String word) {
QueryCondition<Word> where = Word_.langId.equal(langId);
if (sequence != null && word != null) {
where = where.and(Word_.sequenceShort.equal(Word.shrinkSequence(sequence)))
.and(Word_.sequence.equal(sequence))
.and(Word_.word.notEqual(word));
}
try (Query<Word> query = wordBox.query(where).build()) {
long max = query.property(Word_.frequency).max();
return max == Long.MIN_VALUE ? 0 : (int)max;
}
}
public void put(@NonNull List<Word> words) {
wordBox.put(words);
}
public void put(@NonNull Word word) {
wordBox.put(word);
}
public WordStore removeMany(@NonNull ArrayList<Integer> languageIds) {
if (languageIds.size() > 0) {
try (Query<Word> query = wordBox.query(Word_.langId.oneOf(IntegerListToIntArray(languageIds))).build()) {
query.remove();
}
}
return this;
}
public void destroy() {
boxStore.closeThreadResources();
boxStore.close();
boxStore.deleteAllFiles();
}
public void runInTransaction(Runnable r) {
boxStore.runInTx(r);
}
public void runInTransactionAsync(Runnable action) {
boxStore.runInTxAsync(action, null);
}
public void closeThreadResources() {
boxStore.closeThreadResources();
}
private int[] IntegerListToIntArray(ArrayList<Integer> in) {
int[] out = new int[in.size()];
Iterator<Integer> iterator = in.iterator();
for (int i = 0; i < out.length; i++) {
out[i] = iterator.next();
}
return out;
}
}

View file

@ -1,63 +0,0 @@
package io.github.sspanak.tt9.db.room;
import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.sqlite.db.SimpleSQLiteQuery;
import io.github.sspanak.tt9.db.migrations.DB10;
import io.github.sspanak.tt9.db.migrations.DB11;
import io.github.sspanak.tt9.db.migrations.DB6;
import io.github.sspanak.tt9.db.migrations.DB7;
import io.github.sspanak.tt9.db.migrations.DB8;
import io.github.sspanak.tt9.db.migrations.DB9;
@Database(version = 11, entities = Word.class, exportSchema = false)
public abstract class TT9Room extends RoomDatabase {
public abstract WordsDao wordsDao();
public static synchronized TT9Room getInstance(Context context) {
return Room
.databaseBuilder(context, TT9Room.class, "t9dict.db")
.addMigrations(
DB6.MIGRATION,
new DB7().getMigration(context),
DB8.MIGRATION,
DB9.MIGRATION,
DB10.MIGRATION,
new DB11().getMigration(context)
)
.build();
}
public static SimpleSQLiteQuery getFuzzyQuery(String index, int langId, int limit, String sequence, int minWordLength, int maxWordLength, String word) {
String sql = "SELECT *" +
" FROM words INDEXED BY " + index + " " +
" WHERE 1" +
" AND lang = " + langId +
" AND len BETWEEN " + minWordLength + " AND " + maxWordLength +
" AND seq > " + sequence + " AND seq <= " + sequence + "99 " +
" ORDER BY len ASC, freq DESC " +
" LIMIT " + limit;
if (word != null) {
sql = sql.replace("WHERE 1", "WHERE 1 AND word LIKE '" + word.replace("'", "''") + "%'");
}
return new SimpleSQLiteQuery(sql);
}
public static SimpleSQLiteQuery getFuzzyQuery(String index, int langId, int limit, String sequence, int minWordLength, int maxWordLength) {
return getFuzzyQuery(index, langId, limit, sequence, minWordLength, maxWordLength, null);
}
public static SimpleSQLiteQuery createShortWordsIndexQuery() {
return new SimpleSQLiteQuery("CREATE INDEX IF NOT EXISTS " + WordsDao.indexShortWords + " ON words (lang ASC, len ASC, seq ASC)");
}
public static SimpleSQLiteQuery createLongWordsIndexQuery() {
return new SimpleSQLiteQuery("CREATE INDEX IF NOT EXISTS " + WordsDao.indexLongWords + " ON words (lang ASC, seq ASC, freq DESC)");
}
}

View file

@ -1,33 +0,0 @@
package io.github.sspanak.tt9.db.room;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
@Entity(
indices = {
@Index(value = {"lang", "word"}, unique = true),
@Index(value = {"lang", "seq", "freq"}, orders = {Index.Order.ASC, Index.Order.ASC, Index.Order.DESC}),
@Index(value = {"lang", "len", "seq"}, orders = {Index.Order.ASC, Index.Order.ASC, Index.Order.ASC})
},
tableName = "words"
)
public class Word {
@PrimaryKey(autoGenerate = true)
public int id;
@ColumnInfo(name = "lang")
public int langId;
public String word;
@ColumnInfo(name = "seq")
public String sequence;
@ColumnInfo(name = "freq")
public int frequency;
@ColumnInfo(name = "len")
public int length;
}

View file

@ -1,66 +0,0 @@
package io.github.sspanak.tt9.db.room;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.RawQuery;
import androidx.sqlite.db.SimpleSQLiteQuery;
import java.util.ArrayList;
import java.util.List;
@Dao
public interface WordsDao {
String indexLongWords = "index_words_lang_seq_freq";
String indexShortWords = "index_words_lang_len_seq";
@Query("SELECT COUNT(id) FROM words WHERE :langId < 0 OR lang = :langId")
int count(int langId);
@Query("DELETE FROM words WHERE lang IN(:langIds)")
void deleteByLanguage(ArrayList<Integer> langIds);
@Query("SELECT COUNT(id) FROM words WHERE lang = :langId AND word LIKE :word")
int doesWordExist(int langId, String word);
@Query(
"SELECT * " +
"FROM words " +
"WHERE " +
"lang = :langId " +
"AND seq = :sequence " +
"AND (:word IS NULL OR word LIKE :word || '%') " +
"ORDER BY freq DESC " +
"LIMIT :limit"
)
List<Word> getMany(int langId, int limit, String sequence, String word);
@RawQuery(observedEntities = Word.class)
List<Word> getCustom(SimpleSQLiteQuery query);
@Insert
void insert(Word word);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsertMany(List<Word> words);
@Query(
"UPDATE words " +
"SET freq = (SELECT IFNULL(MAX(freq), 0) FROM words WHERE lang = :langId AND seq = :sequence AND word <> :word) + 1 " +
"WHERE lang = :langId AND word = :word AND seq = :sequence"
)
int incrementFrequency(int langId, String word, String sequence);
@Query(
"UPDATE words " +
"SET freq = freq / :normalizationDivider " +
"WHERE lang IN ( " +
"SELECT lang " +
"FROM words " +
"WHERE freq >= :maxFrequency " +
"GROUP BY lang" +
")"
)
int normalizeFrequencies(int normalizationDivider, int maxFrequency);
}

View file

@ -355,7 +355,7 @@ public class TraditionalT9 extends KeyPadHandler {
cancelAutoAccept(); cancelAutoAccept();
clearSuggestions(); clearSuggestions();
String word = textField.getSurroundingWord(); String word = textField.getSurroundingWord(mLanguage);
if (word.isEmpty()) { if (word.isEmpty()) {
UI.toastLong(this, R.string.add_word_no_selection); UI.toastLong(this, R.string.add_word_no_selection);
} else { } else {

View file

@ -18,12 +18,16 @@ import java.util.regex.Pattern;
import io.github.sspanak.tt9.Logger; import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.modes.InputMode; import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
public class TextField { public class TextField {
public static final int IME_ACTION_ENTER = EditorInfo.IME_MASK_ACTION + 1; public static final int IME_ACTION_ENTER = EditorInfo.IME_MASK_ACTION + 1;
private static final Pattern beforeCursorWordRegex = Pattern.compile("(\\w+)(?!\n)$"); private static final Pattern beforeCursorWordRegex = Pattern.compile("(\\w+)(?!\n)$");
private static final Pattern afterCursorWordRegex = Pattern.compile("^(?<!\n)(\\w+)"); private static final Pattern afterCursorWordRegex = Pattern.compile("^(?<!\n)(\\w+)");
private static final Pattern beforeCursorUkrainianRegex = Pattern.compile("([\\w']+)(?!\n)$");
private static final Pattern afterCursorUkrainianRegex = Pattern.compile("^(?<!\n)([\\w']+)");
public final InputConnection connection; public final InputConnection connection;
public final EditorInfo field; public final EditorInfo field;
@ -177,9 +181,20 @@ public class TextField {
* getSurroundingWord * getSurroundingWord
* Returns the word next or around the cursor. Scanning length is up to 50 chars in each direction. * Returns the word next or around the cursor. Scanning length is up to 50 chars in each direction.
*/ */
@NonNull public String getSurroundingWord() { @NonNull public String getSurroundingWord(Language language) {
Matcher before = beforeCursorWordRegex.matcher(getTextBeforeCursor()); Matcher before;
Matcher after = afterCursorWordRegex.matcher(getTextAfterCursor()); Matcher after;
if (language != null && language.isUkrainian()) {
// Ukrainian uses apostrophes as letters
before = beforeCursorUkrainianRegex.matcher(getTextBeforeCursor());
after = afterCursorUkrainianRegex.matcher(getTextAfterCursor());
} else {
// In other languages, special characters in words will cause automatic word break to fail,
// resulting in unexpected suggestions. Therefore, they are not allowed.
before = beforeCursorWordRegex.matcher(getTextBeforeCursor());
after = afterCursorWordRegex.matcher(getTextAfterCursor());
}
return (before.find() ? before.group(1) : "") + (after.find() ? after.group(1) : ""); return (before.find() ? before.group(1) : "") + (after.find() ? after.group(1) : "");
} }

View file

@ -167,6 +167,11 @@ public class Language {
return letters.contains("α"); return letters.contains("α");
} }
public boolean isUkrainian() {
ArrayList<String> letters = getKeyCharacters(4, false);
return letters.contains("ї");
}
/* ************ utility ************ */ /* ************ utility ************ */
/** /**

View file

@ -17,6 +17,7 @@ import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R; import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.DictionaryDb; import io.github.sspanak.tt9.db.DictionaryDb;
import io.github.sspanak.tt9.db.DictionaryLoader; import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.db.SQLWords;
import io.github.sspanak.tt9.ime.helpers.GlobalKeyboardSettings; import io.github.sspanak.tt9.ime.helpers.GlobalKeyboardSettings;
import io.github.sspanak.tt9.ime.helpers.InputModeValidator; import io.github.sspanak.tt9.ime.helpers.InputModeValidator;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys; import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
@ -41,6 +42,7 @@ public class PreferencesActivity extends AppCompatActivity implements Preference
applyTheme(); applyTheme();
Logger.enableDebugLevel(settings.getDebugLogsEnabled()); Logger.enableDebugLevel(settings.getDebugLogsEnabled());
new SQLWords(this).clear();
DictionaryDb.init(this); DictionaryDb.init(this);
DictionaryDb.normalizeWordFrequencies(settings); DictionaryDb.normalizeWordFrequencies(settings);

View file

@ -2,16 +2,20 @@ package io.github.sspanak.tt9.preferences.items;
import androidx.preference.Preference; import androidx.preference.Preference;
import java.util.ArrayList;
import java.util.List;
import io.github.sspanak.tt9.Logger; import io.github.sspanak.tt9.Logger;
abstract class ItemClickable { abstract public class ItemClickable {
protected final int CLICK_DEBOUNCE_TIME = 250; private final int CLICK_DEBOUNCE_TIME = 250;
private long lastClickTime = 0; private long lastClickTime = 0;
protected final Preference item; protected final Preference item;
private final ArrayList<ItemClickable> otherItems = new ArrayList<>();
ItemClickable(Preference item) { public ItemClickable(Preference item) {
this.item = item; this.item = item;
} }
@ -31,6 +35,29 @@ abstract class ItemClickable {
} }
public ItemClickable setOtherItems(List<ItemClickable> others) {
otherItems.clear();
otherItems.addAll(others);
return this;
}
protected void disableOtherItems() {
for (ItemClickable i : otherItems) {
i.disable();
}
}
protected void enableOtherItems() {
for (ItemClickable i : otherItems) {
i.enable();
}
}
/** /**
* debounceClick * debounceClick
* Protection against faulty devices, that sometimes send two (or more) click events * Protection against faulty devices, that sometimes send two (or more) click events

View file

@ -35,11 +35,15 @@ public class ItemLoadDictionary extends ItemClickable {
this.settings = settings; this.settings = settings;
loader.setOnStatusChange(this::onLoadingStatusChange); loader.setOnStatusChange(this::onLoadingStatusChange);
refreshStatus();
}
if (!progressBar.isCompleted() && !progressBar.isFailed()) {
changeToCancelButton(); public void refreshStatus() {
if (loader.isRunning()) {
setLoadingStatus();
} else { } else {
changeToLoadButton(); setReadyStatus();
} }
} }
@ -49,12 +53,12 @@ public class ItemLoadDictionary extends ItemClickable {
item.setSummary(progressBar.getTitle() + " " + progressBar.getMessage()); item.setSummary(progressBar.getTitle() + " " + progressBar.getMessage());
if (progressBar.isCancelled()) { if (progressBar.isCancelled()) {
changeToLoadButton(); setReadyStatus();
} else if (progressBar.isFailed()) { } else if (progressBar.isFailed()) {
changeToLoadButton(); setReadyStatus();
UI.toastFromAsync(context, progressBar.getMessage()); UI.toastFromAsync(context, progressBar.getMessage());
} else if (progressBar.isCompleted()) { } else if (progressBar.isCompleted()) {
changeToLoadButton(); setReadyStatus();
UI.toastFromAsync(context, R.string.dictionary_loaded); UI.toastFromAsync(context, R.string.dictionary_loaded);
} }
} }
@ -65,23 +69,25 @@ public class ItemLoadDictionary extends ItemClickable {
ArrayList<Language> languages = LanguageCollection.getAll(context, settings.getEnabledLanguageIds()); ArrayList<Language> languages = LanguageCollection.getAll(context, settings.getEnabledLanguageIds());
try { try {
setLoadingStatus();
loader.load(languages); loader.load(languages);
changeToCancelButton();
} catch (DictionaryImportAlreadyRunningException e) { } catch (DictionaryImportAlreadyRunningException e) {
loader.stop(); loader.stop();
changeToLoadButton(); setReadyStatus();
} }
return true; return true;
} }
public void changeToCancelButton() { private void setLoadingStatus() {
disableOtherItems();
item.setTitle(context.getString(R.string.dictionary_cancel_load)); item.setTitle(context.getString(R.string.dictionary_cancel_load));
} }
public void changeToLoadButton() { private void setReadyStatus() {
enableOtherItems();
item.setTitle(context.getString(R.string.dictionary_load_title)); item.setTitle(context.getString(R.string.dictionary_load_title));
item.setSummary(progressBar.isFailed() || progressBar.isCancelled() ? progressBar.getMessage() : ""); item.setSummary(progressBar.isFailed() || progressBar.isCancelled() ? progressBar.getMessage() : "");
} }

View file

@ -14,42 +14,30 @@ public class ItemTruncateAll extends ItemClickable {
protected final PreferencesActivity activity; protected final PreferencesActivity activity;
protected final DictionaryLoader loader; protected final DictionaryLoader loader;
protected final ItemLoadDictionary loadItem;
protected ItemClickable otherTruncateItem;
public ItemTruncateAll(Preference item, ItemLoadDictionary loadItem, PreferencesActivity activity, DictionaryLoader loader) {
public ItemTruncateAll(Preference item, PreferencesActivity activity, DictionaryLoader loader) {
super(item); super(item);
this.activity = activity; this.activity = activity;
this.loadItem = loadItem;
this.loader = loader; this.loader = loader;
} }
public ItemTruncateAll setOtherTruncateItem(ItemTruncateUnselected item) {
this.otherTruncateItem = item;
return this;
}
@Override @Override
protected boolean onClick(Preference p) { protected boolean onClick(Preference p) {
if (loader != null && loader.isRunning()) { if (loader != null && loader.isRunning()) {
loader.stop(); return false;
loadItem.changeToLoadButton();
} }
onStartDeleting(); onStartDeleting();
DictionaryDb.deleteWords(this::onFinishDeleting); DictionaryDb.deleteWords(activity.getApplicationContext(), this::onFinishDeleting);
return true; return true;
} }
protected void onStartDeleting() { protected void onStartDeleting() {
if (otherTruncateItem != null) { disableOtherItems();
otherTruncateItem.disable();
}
loadItem.disable();
disable(); disable();
item.setSummary(R.string.dictionary_truncating); item.setSummary(R.string.dictionary_truncating);
} }
@ -57,10 +45,7 @@ public class ItemTruncateAll extends ItemClickable {
protected void onFinishDeleting() { protected void onFinishDeleting() {
activity.runOnUiThread(() -> { activity.runOnUiThread(() -> {
if (otherTruncateItem != null) { enableOtherItems();
otherTruncateItem.enable();
}
loadItem.enable();
item.setSummary(""); item.setSummary("");
enable(); enable();
UI.toastFromAsync(activity, R.string.dictionary_truncated); UI.toastFromAsync(activity, R.string.dictionary_truncated);

View file

@ -18,23 +18,17 @@ public class ItemTruncateUnselected extends ItemTruncateAll {
private final SettingsStore settings; private final SettingsStore settings;
public ItemTruncateUnselected(Preference item, ItemLoadDictionary loadItem, PreferencesActivity context, SettingsStore settings, DictionaryLoader loader) { public ItemTruncateUnselected(Preference item, PreferencesActivity context, SettingsStore settings, DictionaryLoader loader) {
super(item, loadItem, context, loader); super(item, context, loader);
this.settings = settings; this.settings = settings;
} }
public ItemTruncateUnselected setOtherTruncateItem(ItemTruncateAll otherTruncateItem) {
this.otherTruncateItem = otherTruncateItem;
return this;
}
@Override @Override
protected boolean onClick(Preference p) { protected boolean onClick(Preference p) {
if (loader != null && loader.isRunning()) { if (loader != null && loader.isRunning()) {
loader.stop(); return false;
loadItem.changeToLoadButton();
} }
ArrayList<Integer> unselectedLanguageIds = new ArrayList<>(); ArrayList<Integer> unselectedLanguageIds = new ArrayList<>();

View file

@ -1,5 +1,7 @@
package io.github.sspanak.tt9.preferences.screens; package io.github.sspanak.tt9.preferences.screens;
import java.util.Arrays;
import io.github.sspanak.tt9.R; import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity; import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.ItemLoadDictionary; import io.github.sspanak.tt9.preferences.items.ItemLoadDictionary;
@ -8,6 +10,10 @@ import io.github.sspanak.tt9.preferences.items.ItemTruncateAll;
import io.github.sspanak.tt9.preferences.items.ItemTruncateUnselected; import io.github.sspanak.tt9.preferences.items.ItemTruncateUnselected;
public class DictionariesScreen extends BaseScreenFragment { public class DictionariesScreen extends BaseScreenFragment {
private ItemLoadDictionary loadItem;
private ItemTruncateUnselected deleteItem;
private ItemTruncateAll truncateItem;
public DictionariesScreen() { init(); } public DictionariesScreen() { init(); }
public DictionariesScreen(PreferencesActivity activity) { init(activity); } public DictionariesScreen(PreferencesActivity activity) { init(activity); }
@ -23,31 +29,35 @@ public class DictionariesScreen extends BaseScreenFragment {
); );
multiSelect.populate().enableValidation(); multiSelect.populate().enableValidation();
ItemLoadDictionary loadItem = new ItemLoadDictionary( loadItem = new ItemLoadDictionary(
findPreference(ItemLoadDictionary.NAME), findPreference(ItemLoadDictionary.NAME),
activity, activity,
activity.settings, activity.settings,
activity.getDictionaryLoader(), activity.getDictionaryLoader(),
activity.getDictionaryProgressBar() activity.getDictionaryProgressBar()
); );
loadItem.enableClickHandler();
ItemTruncateAll truncateItem = new ItemTruncateAll( deleteItem = new ItemTruncateUnselected(
findPreference(ItemTruncateAll.NAME),
loadItem,
activity,
activity.getDictionaryLoader()
);
ItemTruncateUnselected truncateSelectedItem = new ItemTruncateUnselected(
findPreference(ItemTruncateUnselected.NAME), findPreference(ItemTruncateUnselected.NAME),
loadItem,
activity, activity,
activity.settings, activity.settings,
activity.getDictionaryLoader() activity.getDictionaryLoader()
); );
truncateItem.setOtherTruncateItem(truncateSelectedItem).enableClickHandler(); truncateItem = new ItemTruncateAll(
truncateSelectedItem.setOtherTruncateItem(truncateItem).enableClickHandler(); findPreference(ItemTruncateAll.NAME),
activity,
activity.getDictionaryLoader()
);
loadItem.setOtherItems(Arrays.asList(truncateItem, deleteItem)).enableClickHandler();
deleteItem.setOtherItems(Arrays.asList(truncateItem, loadItem)).enableClickHandler();
truncateItem.setOtherItems(Arrays.asList(deleteItem, loadItem)).enableClickHandler();
}
@Override
public void onResume() {
super.onResume();
loadItem.refreshStatus();
} }
} }

View file

@ -106,6 +106,7 @@ public class DictionaryLoadingBar {
public void show(Context context, Bundle data) { public void show(Context context, Bundle data) {
String error = data.getString("error", null); String error = data.getString("error", null);
int fileCount = data.getInt("fileCount", -1); int fileCount = data.getInt("fileCount", -1);
int progress = data.getInt("progress", -1);
if (error != null) { if (error != null) {
hasFailed = true; hasFailed = true;
@ -116,10 +117,12 @@ public class DictionaryLoadingBar {
data.getLong("fileLine", -1), data.getLong("fileLine", -1),
data.getString("word", "") data.getString("word", "")
); );
} else if (fileCount > -1) { } else if (progress >= 0) {
setFileCount(fileCount);
} else {
hasFailed = false; hasFailed = false;
if (fileCount >= 0) {
setFileCount(fileCount);
}
showProgress( showProgress(
context, context,
data.getLong("time", 0), data.getLong("time", 0),
@ -143,7 +146,7 @@ public class DictionaryLoadingBar {
private void showProgress(Context context, long time, int currentFile, int currentFileProgress, int languageId) { private void showProgress(Context context, long time, int currentFile, int currentFileProgress, int languageId) {
if (currentFileProgress < 0) { if (currentFileProgress <= 0) {
hide(); hide();
isStopped = true; isStopped = true;
title = ""; title = "";