1
0
Fork 0

Speed Optimizations (#250)

* emoji micro optimizations

	* Predictive Mode: reduced post-load processing time from 20 ms to <1 ms

	* EmptyDatabaseWarning does not run unnecessary queries, when the time for the next warning has not come yet

	* replaced unnecessary Handler with lambdas in EmptyDatabaseWarning and Add Word dialog

	* slightly simplified the word searching, importing and clearing code

	* word length is now stored in the database, instead of being calculated on-the-fly, while searching

	* created sepearate indexes and queries for 2-letter and longer words; suggestion loading time reduced from 50-60 ms, peaking to: 200 ms, down to: <20 ms with peaks to: 50-60 ms.

	* imporved dictionary loading speed by temporarily disabling the database indexes
This commit is contained in:
Dimo Karaivanov 2023-05-10 09:50:41 +03:00 committed by GitHub
parent 4d67c02340
commit c3787138fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 507 additions and 316 deletions

View file

@ -0,0 +1,10 @@
package io.github.sspanak.tt9;
/**
* ConsumerCompat
* A fallback interface for Consumer in API < 24
*/
public interface ConsumerCompat<T>{
void accept(T t);
default ConsumerCompat<T> andThen(ConsumerCompat<? super T> after) {return null;}
}

View file

@ -5,6 +5,10 @@ import android.util.Log;
public class Logger {
public static final int LEVEL = BuildConfig.DEBUG ? Log.DEBUG : Log.ERROR;
static public boolean isDebugLevel() {
return LEVEL == Log.DEBUG;
}
static public void v(String tag, String msg) {
if (LEVEL <= Log.VERBOSE) {
Log.v(tag, msg);

View file

@ -2,17 +2,20 @@ package io.github.sspanak.tt9.db;
import android.content.Context;
import android.database.sqlite.SQLiteConstraintException;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import androidx.sqlite.db.SimpleSQLiteQuery;
import java.util.ArrayList;
import java.util.List;
import io.github.sspanak.tt9.ConsumerCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.exceptions.InsertBlankWordException;
import io.github.sspanak.tt9.db.room.TT9Room;
import io.github.sspanak.tt9.db.room.Word;
import io.github.sspanak.tt9.db.room.WordList;
import io.github.sspanak.tt9.db.room.WordsDao;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.InvalidLanguageException;
import io.github.sspanak.tt9.languages.Language;
@ -21,6 +24,7 @@ import io.github.sspanak.tt9.preferences.SettingsStore;
public class DictionaryDb {
private static TT9Room dbInstance;
private static final Handler asyncHandler = new Handler();
public static synchronized void init(Context context) {
if (dbInstance == null) {
@ -41,13 +45,51 @@ public class DictionaryDb {
}
private static void printDebug(String tag, String title, WordList words, long startTime) {
if (!Logger.isDebugLevel()) {
return;
}
StringBuilder debugText = new StringBuilder(title);
debugText
.append("\n")
.append("Word Count: ").append(words.size())
.append(". Time: ").append(System.currentTimeMillis() - startTime).append(" ms");
if (words.size() > 0) {
debugText.append("\n").append(words);
}
Logger.d(tag, debugText.toString());
}
public static void runInTransaction(Runnable r) {
getInstance().runInTransaction(r);
}
public static void createShortWordIndexSync() {
getInstance().wordsDao().rawQuery(TT9Room.createShortWordsIndexQuery());
}
public static void createLongWordIndexSync() {
getInstance().wordsDao().rawQuery(TT9Room.createLongWordsIndexQuery());
}
public static void dropShortWordIndexSync() {
getInstance().wordsDao().rawQuery(TT9Room.dropShortWordsIndexQuery());
}
public static void dropLongWordIndexSync() {
getInstance().wordsDao().rawQuery(TT9Room.dropLongWordsIndexQuery());
}
/**
* normalizeWordFrequencies
* Normalizes the word frequencies for all languages that have reached the maximum, as defined in
* the settings.
*
* This query will finish immediately, if there is nothing to do. It's safe to run it often.
*
*/
public static void normalizeWordFrequencies(SettingsStore settings) {
new Thread() {
@ -55,7 +97,7 @@ public class DictionaryDb {
public void run() {
long time = System.currentTimeMillis();
int affectedRows = dbInstance.wordsDao().normalizeFrequencies(
int affectedRows = getInstance().wordsDao().normalizeFrequencies(
settings.getWordFrequencyNormalizationDivider(),
settings.getWordFrequencyMax()
);
@ -69,17 +111,12 @@ public class DictionaryDb {
}
public static void runInTransaction(Runnable r) {
getInstance().runInTransaction(r);
}
public static void areThereWords(Handler handler, Language language) {
public static void areThereWords(ConsumerCompat<Boolean> notification, Language language) {
new Thread() {
@Override
public void run() {
int langId = language != null ? language.getId() : -1;
handler.sendEmptyMessage(getInstance().wordsDao().count(langId) > 0 ? 1 : 0);
notification.accept(getInstance().wordsDao().count(langId) > 0);
}
}.start();
}
@ -94,12 +131,12 @@ public class DictionaryDb {
}
public static void deleteWords(Handler handler) {
deleteWords(handler, null);
public static void deleteWords(Runnable notification) {
deleteWords(notification, null);
}
public static void deleteWords(Handler handler, ArrayList<Integer> languageIds) {
public static void deleteWords(Runnable notification, ArrayList<Integer> languageIds) {
new Thread() {
@Override
public void run() {
@ -108,13 +145,13 @@ public class DictionaryDb {
} else if (languageIds.size() > 0) {
getInstance().wordsDao().deleteByLanguage(languageIds);
}
handler.sendEmptyMessage(0);
notification.run();
}
}.start();
}
public static void insertWord(Handler handler, Language language, String word) throws Exception {
public static void insertWord(ConsumerCompat<Integer> statusHandler, Language language, String word) throws Exception {
if (language == null) {
throw new InvalidLanguageException();
}
@ -127,6 +164,7 @@ public class DictionaryDb {
dbWord.langId = language.getId();
dbWord.sequence = language.getDigitSequenceForWord(word);
dbWord.word = word.toLowerCase(language.getLocale());
dbWord.length = word.length();
dbWord.frequency = 1;
new Thread() {
@ -135,15 +173,16 @@ public class DictionaryDb {
try {
getInstance().wordsDao().insert(dbWord);
getInstance().wordsDao().incrementFrequency(dbWord.langId, dbWord.word, dbWord.sequence);
handler.sendEmptyMessage(0);
statusHandler.accept(0);
} catch (SQLiteConstraintException e) {
String msg = "Constraint violation when inserting a word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId;
String msg = "Constraint violation when inserting a word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId
+ ". " + e.getMessage();
Logger.e("tt9/insertWord", msg);
handler.sendEmptyMessage(1);
statusHandler.accept(1);
} catch (Exception e) {
String msg = "Failed inserting word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId;
String msg = "Failed inserting word: '" + dbWord.word + "' / sequence: '" + dbWord.sequence + "', for language: " + dbWord.langId + ". " + e.getMessage();
Logger.e("tt9/insertWord", msg);
handler.sendEmptyMessage(2);
statusHandler.accept(2);
}
}
}.start();
@ -211,101 +250,97 @@ public class DictionaryDb {
}
private static ArrayList<String> getSuggestionsExact(Language language, String sequence, String word, int maximumWords) {
/**
* loadWordsExact
* Loads words that match exactly the "sequence" and the optional "filter".
* For example: "7655" gets "roll".
*/
private static ArrayList<String> loadWordsExact(Language language, String sequence, String filter, int maximumWords) {
long start = System.currentTimeMillis();
List<Word> exactMatches = getInstance().wordsDao().getMany(
WordList matches = new WordList(getInstance().wordsDao().getMany(
language.getId(),
maximumWords,
sequence,
word == null || word.equals("") ? null : word
);
Logger.d(
"db.getSuggestionsExact",
"Exact matches: " + exactMatches.size() + ". Time: " + (System.currentTimeMillis() - start) + " ms"
);
filter == null || filter.equals("") ? null : filter
));
ArrayList<String> suggestions = new ArrayList<>();
for (Word w : exactMatches) {
Logger.d("db.getSuggestions", "exact match: " + w.word + " | priority: " + w.frequency);
suggestions.add(w.word);
}
return suggestions;
printDebug("loadWordsExact", "===== Exact Word Matches =====", matches, start);
return matches.toStringList();
}
private static ArrayList<String> getSuggestionsFuzzy(Language language, String sequence, String word, int maximumWords) {
/**
* loadWordsFuzzy
* Loads words that start with "sequence" and optionally match the "filter".
* 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();
List<Word> extraWords = getInstance().wordsDao().getFuzzy(
// fuzzy queries are heavy, so we must restrict the search range as much as possible
boolean noFilter = (filter == null || filter.equals(""));
int maxWordLength = noFilter && sequence.length() <= 2 ? 5 : 1000;
String index = sequence.length() <= 2 ? WordsDao.indexShortWords : WordsDao.indexLongWords;
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,
maximumWords - matches.size(),
sequence,
word == null || word.equals("") ? null : word
5,
1000
);
Logger.d(
"db.getSuggestionsFuzzy",
"Fuzzy matches: " + extraWords.size() + ". Time: " + (System.currentTimeMillis() - start) + " ms"
);
ArrayList<String> suggestions = new ArrayList<>();
for (Word w : extraWords) {
Logger.d(
"db.getSuggestions",
"fuzzy match: " + w.word + " | sequence: " + w.sequence + " | priority: " + w.frequency
);
suggestions.add(w.word);
matches.addAll(getInstance().wordsDao().getCustom(sql));
}
return suggestions;
printDebug("loadWordsFuzzy", "~=~=~=~ Fuzzy Word Matches ~=~=~=~", matches, start);
return matches.toStringList();
}
private static void sendSuggestions(Handler handler, ArrayList<String> data) {
Bundle bundle = new Bundle();
bundle.putStringArrayList("suggestions", data);
Message msg = new Message();
msg.setData(bundle);
handler.sendMessage(msg);
private static void sendWords(ConsumerCompat<ArrayList<String>> dataHandler, ArrayList<String> wordList) {
asyncHandler.post(() -> dataHandler.accept(wordList));
}
public static void getSuggestions(Handler handler, Language language, String sequence, String word, int minimumWords, int maximumWords) {
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("tt9/db.getSuggestions", "Attempting to get suggestions for an empty sequence.");
sendSuggestions(handler, new ArrayList<>());
Logger.w("tt9/db.getWords", "Attempting to get words for an empty sequence.");
sendWords(dataHandler, wordList);
return;
}
if (language == null) {
Logger.w("tt9/db.getSuggestions", "Attempting to get suggestions for NULL language.");
sendSuggestions(handler, new ArrayList<>());
Logger.w("tt9/db.getWords", "Attempting to get words for NULL language.");
sendWords(dataHandler, wordList);
return;
}
new Thread() {
@Override
public void run() {
// get exact sequence matches, for example: "9422" -> "what"
ArrayList<String> suggestions = getSuggestionsExact(language, sequence, word, maxWords);
// if the exact matches are too few, add some more words that start with the same characters,
// for example: "rol" -> "roll", "roller", "rolling", ...
if (suggestions.size() < minWords && sequence.length() >= 2) {
suggestions.addAll(
getSuggestionsFuzzy(language, sequence, word, minWords - suggestions.size())
);
if (sequence.length() == 1) {
wordList.addAll(loadWordsExact(language, sequence, filter, maxWords));
} else {
wordList.addAll(loadWordsFuzzy(language, sequence, filter, minWords));
}
if (suggestions.size() == 0) {
Logger.i("db.getSuggestions", "No suggestions for sequence: " + sequence);
if (wordList.size() == 0) {
Logger.i("db.getWords", "No suggestions for sequence: " + sequence);
}
// pack the words in a message and send it to the calling thread
sendSuggestions(handler, suggestions);
sendWords(dataHandler, wordList);
}
}.start();
}

View file

@ -4,7 +4,6 @@ import android.content.Context;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import java.io.BufferedReader;
import java.io.InputStreamReader;
@ -12,6 +11,7 @@ import java.io.LineNumberReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import io.github.sspanak.tt9.ConsumerCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAlreadyRunningException;
@ -28,7 +28,8 @@ public class DictionaryLoader {
private final AssetManager assets;
private final SettingsStore settings;
private Handler statusHandler = null;
private static final Handler asyncHandler = new Handler();
private ConsumerCompat<Bundle> onStatusChange;
private Thread loadThread;
private long importStartTime = 0;
@ -53,8 +54,8 @@ public class DictionaryLoader {
}
public void setStatusHandler(Handler handler) {
statusHandler = handler;
public void setOnStatusChange(ConsumerCompat<Bundle> callback) {
onStatusChange = callback;
}
@ -68,11 +69,19 @@ public class DictionaryLoader {
throw new DictionaryImportAlreadyRunningException();
}
if (languages.size() == 0) {
Logger.d("DictionaryLoader", "Nothing to do");
return;
}
loadThread = new Thread() {
@Override
public void run() {
currentFile = 0;
importStartTime = System.currentTimeMillis();
dropIndexes();
// SQLite does not support parallel queries, so let's import them one by one
for (Language lang : languages) {
if (isInterrupted()) {
@ -81,6 +90,8 @@ public class DictionaryLoader {
importAll(lang);
currentFile++;
}
createIndexes();
}
};
@ -157,6 +168,28 @@ public class DictionaryLoader {
}
private void dropIndexes() {
long start = System.currentTimeMillis();
DictionaryDb.dropLongWordIndexSync();
Logger.d("dropIndexes", "Index 1: " + (System.currentTimeMillis() - start) + " ms");
start = System.currentTimeMillis();
DictionaryDb.dropShortWordIndexSync();
Logger.d("dropIndexes", "Index 2: " + (System.currentTimeMillis() - start) + " ms");
}
private void createIndexes() {
long start = System.currentTimeMillis();
DictionaryDb.createLongWordIndexSync();
Logger.d("createIndexes", "Index 1: " + (System.currentTimeMillis() - start) + " ms");
start = System.currentTimeMillis();
DictionaryDb.createShortWordIndexSync();
Logger.d("createIndexes", "Index 2: " + (System.currentTimeMillis() - start) + " ms");
}
private void importLetters(Language language) {
ArrayList<Word> letters = new ArrayList<>();
@ -176,6 +209,7 @@ public class DictionaryLoader {
Word word = new Word();
word.langId = language.getId();
word.frequency = 0;
word.length = 1;
word.sequence = String.valueOf(key);
word.word = langChar;
@ -280,6 +314,7 @@ public class DictionaryLoader {
Word dbWord = new Word();
dbWord.langId = language.getId();
dbWord.frequency = frequency;
dbWord.length = word.length();
dbWord.sequence = language.getDigitSequenceForWord(word);
dbWord.word = word;
@ -288,7 +323,7 @@ public class DictionaryLoader {
private void sendProgressMessage(Language language, int progress, int progressUpdateInterval) {
if (statusHandler == null) {
if (onStatusChange == null) {
Logger.w(
"tt9/DictionaryLoader.sendProgressMessage",
"Cannot send progress without a status Handler. Ignoring message.");
@ -302,45 +337,39 @@ public class DictionaryLoader {
lastProgressUpdate = now;
Bundle bundle = new Bundle();
bundle.putInt("languageId", language.getId());
bundle.putLong("time", getImportTime());
bundle.putInt("progress", progress);
bundle.putInt("currentFile", currentFile);
Message msg = new Message();
msg.setData(bundle);
statusHandler.sendMessage(msg);
Bundle progressMsg = new Bundle();
progressMsg.putInt("languageId", language.getId());
progressMsg.putLong("time", getImportTime());
progressMsg.putInt("progress", progress);
progressMsg.putInt("currentFile", currentFile);
asyncHandler.post(() -> onStatusChange.accept(progressMsg));
}
private void sendError(String message, int langId) {
if (statusHandler == null) {
if (onStatusChange == null) {
Logger.w("tt9/DictionaryLoader.sendError", "Cannot send an error without a status Handler. Ignoring message.");
return;
}
Bundle bundle = new Bundle();
bundle.putString("error", message);
bundle.putInt("languageId", langId);
Message msg = new Message();
msg.setData(bundle);
statusHandler.sendMessage(msg);
Bundle errorMsg = new Bundle();
errorMsg.putString("error", message);
errorMsg.putInt("languageId", langId);
asyncHandler.post(() -> onStatusChange.accept(errorMsg));
}
private void sendImportError(String message, int langId, long fileLine, String word) {
if (statusHandler == null) {
if (onStatusChange == null) {
Logger.w("tt9/DictionaryLoader.sendError", "Cannot send an import error without a status Handler. Ignoring message.");
return;
}
Bundle bundle = new Bundle();
bundle.putString("error", message);
bundle.putLong("fileLine", fileLine + 1);
bundle.putInt("languageId", langId);
bundle.putString("word", word);
Message msg = new Message();
msg.setData(bundle);
statusHandler.sendMessage(msg);
Bundle errorMsg = new Bundle();
errorMsg.putString("error", message);
errorMsg.putLong("fileLine", fileLine + 1);
errorMsg.putInt("languageId", langId);
errorMsg.putString("word", word);
asyncHandler.post(() -> onStatusChange.accept(errorMsg));
}
}

View file

@ -0,0 +1,27 @@
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

@ -0,0 +1,27 @@
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

@ -5,18 +5,63 @@ 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.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 = 7, entities = Word.class, exportSchema = false)
@Database(version = 9, 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))
.addMigrations(
DB6.MIGRATION,
new DB7().getMigration(context),
DB8.MIGRATION,
DB9.MIGRATION
)
.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 BETWEEN " + sequence + " AND " + sequence + "99 " +
" ORDER BY len ASC, freq DESC " +
" LIMIT " + limit;
if (word != null) {
sql = sql.replace("WHERE 1", "WHERE 1 AND word LIKE '" + word + "%'");
}
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 " + WordsDao.indexShortWords + " ON words (lang ASC, len ASC, seq ASC)");
}
public static SimpleSQLiteQuery createLongWordsIndexQuery() {
return new SimpleSQLiteQuery("CREATE INDEX " + WordsDao.indexLongWords + " ON words (lang ASC, seq ASC, freq DESC)");
}
public static SimpleSQLiteQuery dropShortWordsIndexQuery() {
return new SimpleSQLiteQuery("DROP INDEX IF EXISTS " + WordsDao.indexShortWords);
}
public static SimpleSQLiteQuery dropLongWordsIndexQuery() {
return new SimpleSQLiteQuery("DROP INDEX IF EXISTS " + WordsDao.indexLongWords);
}
}

View file

@ -8,7 +8,8 @@ import androidx.room.PrimaryKey;
@Entity(
indices = {
@Index(value = {"lang", "word"}, unique = true),
@Index(value = {"lang", "seq", "freq"})
@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"
)
@ -26,4 +27,7 @@ public class Word {
@ColumnInfo(name = "freq")
public int frequency;
@ColumnInfo(name = "len")
public int length;
}

View file

@ -0,0 +1,41 @@
package io.github.sspanak.tt9.db.room;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
public class WordList extends ArrayList<Word> {
public WordList() {
super();
}
public WordList(List<Word> words) {
addAll(words);
}
@NonNull
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size(); i++) {
sb
.append("word: ").append(get(i).word)
.append(" | sequence: ").append(get(i).sequence)
.append(" | priority: ").append(get(i).frequency)
.append("\n");
}
return sb.toString();
}
@NonNull
public ArrayList<String> toStringList() {
ArrayList<String> strings = new ArrayList<>();
for (int i = 0; i < size(); i++) {
strings.add(get(i).word);
}
return strings;
}
}

View file

@ -4,12 +4,20 @@ 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";
@RawQuery()
Object rawQuery(SimpleSQLiteQuery sql);
@Query("SELECT COUNT(id) FROM words WHERE :langId < 0 OR lang = :langId")
int count(int langId);
@ -31,17 +39,8 @@ public interface WordsDao {
)
List<Word> getMany(int langId, int limit, String sequence, String word);
@Query(
"SELECT * " +
"FROM words " +
"WHERE " +
"lang = :langId " +
"AND seq > :sequence AND seq <= :sequence || '99' " +
"AND (:word IS NULL OR word LIKE :word || '%') " +
"ORDER BY LENGTH(seq) ASC, freq DESC, seq ASC " +
"LIMIT :limit"
)
List<Word> getFuzzy(int langId, int limit, String sequence, String word);
@RawQuery(observedEntities = Word.class)
List<Word> getCustom(SimpleSQLiteQuery query);
@Insert
void insert(Word word);

View file

@ -1,9 +1,6 @@
package io.github.sspanak.tt9.ime;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import java.util.HashMap;
@ -18,6 +15,7 @@ public class EmptyDatabaseWarning {
final int WARNING_INTERVAL;
private static final HashMap<Integer, Long> warningDisplayedTime = new HashMap<>();
private Context context;
private Language language;
public EmptyDatabaseWarning(SettingsStore settings) {
@ -31,37 +29,33 @@ public class EmptyDatabaseWarning {
}
public void emitOnce(Language language) {
if (language == null) {
return;
}
context = context == null ? TraditionalT9.getMainContext() : context;
this.language = language;
DictionaryDb.areThereWords(handleWordCount, language);
if (isItTimeAgain(TraditionalT9.getMainContext())) {
DictionaryDb.areThereWords(this::show, language);
}
}
private final Handler handleWordCount = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
boolean areThereWords = msg.what == 1;
if (areThereWords) {
return;
}
Context context = TraditionalT9.getMainContext();
if (context == null || !warningDisplayedTime.containsKey(language.getId())) {
return;
private boolean isItTimeAgain(Context context) {
if (this.language == null || context == null || !warningDisplayedTime.containsKey(language.getId())) {
return false;
}
long now = System.currentTimeMillis();
Long lastWarningTime = warningDisplayedTime.get(language.getId());
boolean isItWarningTimeAgain = lastWarningTime != null && now - lastWarningTime > WARNING_INTERVAL;
return lastWarningTime != null && now - lastWarningTime > WARNING_INTERVAL;
}
if (isItWarningTimeAgain) {
String message = context.getString(R.string.dictionary_missing_go_load_it, language.getName());
UI.toastLong(context, message);
warningDisplayedTime.put(language.getId(), now);
private void show(boolean areThereWords) {
if (areThereWords) {
return;
}
warningDisplayedTime.put(language.getId(), System.currentTimeMillis());
UI.toastLongFromAsync(
context,
context.getString(R.string.dictionary_missing_go_load_it, language.getName())
);
}
}
};
}

View file

@ -2,9 +2,6 @@ package io.github.sspanak.tt9.ime;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
@ -251,7 +248,7 @@ public class TraditionalT9 extends KeyPadHandler {
protected boolean onLeft() {
if (mInputMode.clearWordStem()) {
mInputMode.loadSuggestions(handleSuggestionsAsync, getComposingText());
mInputMode.loadSuggestions(this::getSuggestions, getComposingText());
} else {
jumpBeforeComposingText();
}
@ -269,7 +266,7 @@ public class TraditionalT9 extends KeyPadHandler {
}
if (mInputMode.setWordStem(filter, repeat)) {
mInputMode.loadSuggestions(handleSuggestionsAsync, filter);
mInputMode.loadSuggestions(this::getSuggestions, filter);
} else if (filter.length() == 0) {
mInputMode.reset();
}
@ -469,9 +466,7 @@ public class TraditionalT9 extends KeyPadHandler {
private void getSuggestions() {
if (!mInputMode.loadSuggestions(handleSuggestionsAsync, suggestionBar.getCurrentSuggestion())) {
handleSuggestions();
}
mInputMode.loadSuggestions(this::handleSuggestions, suggestionBar.getCurrentSuggestion());
}
@ -500,14 +495,6 @@ public class TraditionalT9 extends KeyPadHandler {
}
private final Handler handleSuggestionsAsync = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message m) {
handleSuggestions();
}
};
private void setSuggestions(List<String> suggestions) {
setSuggestions(suggestions, 0);
}

View file

@ -1,7 +1,5 @@
package io.github.sspanak.tt9.ime.modes;
import android.os.Handler;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
@ -50,10 +48,18 @@ abstract public class InputMode {
public boolean onBackspace() { return false; }
abstract public boolean onNumber(int number, boolean hold, int repeat);
// Predictions
// Suggestions
public void onAcceptSuggestion(String suggestion) {}
protected void onSuggestionsUpdated(Handler handler) { handler.sendEmptyMessage(0); }
public boolean loadSuggestions(Handler handler, String currentWord) { return false; }
/**
* loadSuggestions
* Loads the suggestions based on the current state, with optional "currentWord" filter.
* Once loading is finished the respective InputMode child will call "notification", notifying it
* the suggestions are available using "getSuggestions()".
*/
public void loadSuggestions(Runnable notification, String currentWord) {
notification.run();
}
public ArrayList<String> getSuggestions() {
ArrayList<String> newSuggestions = new ArrayList<>();

View file

@ -1,9 +1,5 @@
package io.github.sspanak.tt9.ime.modes;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.Logger;
@ -30,7 +26,8 @@ public class ModePredictive extends InputMode {
private String stem = "";
// async suggestion handling
private static Handler handleSuggestionsExternal;
private boolean disablePredictions = false;
private Runnable onSuggestionsUpdated;
// text analysis tools
private final AutoSpace autoSpace;
@ -73,10 +70,12 @@ public class ModePredictive extends InputMode {
// hold to type any digit
reset();
autoAcceptTimeout = 0;
disablePredictions = true;
suggestions.add(String.valueOf(number));
} else {
// words
super.reset();
disablePredictions = false;
digitSequence += number;
if (number == 0 && repeat > 0) {
autoAcceptTimeout = 0;
@ -104,6 +103,7 @@ public class ModePredictive extends InputMode {
public void reset() {
super.reset();
digitSequence = "";
disablePredictions = false;
stem = "";
}
@ -191,36 +191,35 @@ public class ModePredictive extends InputMode {
* See: Predictions.generatePossibleCompletions()
*/
@Override
public boolean loadSuggestions(Handler handler, String currentWord) {
public void loadSuggestions(Runnable handler, String currentWord) {
if (disablePredictions) {
super.loadSuggestions(handler, currentWord);
return;
}
onSuggestionsUpdated = handler;
predictions
.setDigitSequence(digitSequence)
.setIsStemFuzzy(isStemFuzzy)
.setStem(stem)
.setLanguage(language)
.setInputWord(currentWord)
.setWordsChangedHandler(handleSuggestions);
handleSuggestionsExternal = handler;
return predictions.load();
.setWordsChangedHandler(this::getPredictions)
.load();
}
/**
* handleSuggestions
* Extracts the suggestions from the Message object and passes them to the actual external Handler.
* If there were no matches in the database, they will be generated based on the "lastInputFieldWord".
* getPredictions
* Gets the currently available Predictions and sends them over to the external caller.
*/
private final Handler handleSuggestions = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message m) {
private void getPredictions() {
digitSequence = predictions.getDigitSequence();
suggestions.clear();
suggestions.addAll(predictions.getList());
onSuggestionsUpdated(handleSuggestionsExternal);
onSuggestionsUpdated.run();
}
};
/**

View file

@ -1,9 +1,5 @@
package io.github.sspanak.tt9.ime.modes.helpers;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import java.util.ArrayList;
import java.util.regex.Pattern;
@ -24,7 +20,7 @@ public class Predictions {
private String inputWord;
// async operations
private Handler wordsChangedHandler;
private Runnable onWordsChanged = () -> {};
// data
private final ArrayList<String> words = new ArrayList<>();
@ -77,8 +73,9 @@ public class Predictions {
return this;
}
public void setWordsChangedHandler(Handler handler) {
wordsChangedHandler = handler;
public Predictions setWordsChangedHandler(Runnable handler) {
onWordsChanged = handler;
return this;
}
public ArrayList<String> getList() {
@ -111,35 +108,23 @@ public class Predictions {
}
/**
* onWordsChanged
* Notify the external handler the word list has changed, so they can get the new ones using getList().
*/
private void onWordsChanged() {
wordsChangedHandler.sendEmptyMessage(0);
}
/**
* load
* Queries the dictionary database for a list of words matching the current language and
* sequence or loads the static ones.
*
* Returns "false" on invalid digitSequence.
*/
public boolean load() {
public void load() {
if (digitSequence == null || digitSequence.length() == 0) {
words.clear();
onWordsChanged();
return false;
onWordsChanged.run();
return;
}
if (loadStatic()) {
onWordsChanged();
onWordsChanged.run();
} else {
DictionaryDb.getSuggestions(
dbWordsHandler,
DictionaryDb.getWords(
this::onDbWords,
language,
digitSequence,
stem,
@ -147,8 +132,6 @@ public class Predictions {
settings.getSuggestionsMax()
);
}
return true;
}
@ -160,20 +143,20 @@ public class Predictions {
private boolean loadStatic() {
// whitespace/special/math characters
if (digitSequence.equals("0")) {
words.clear();
stem = "";
words.clear();
words.addAll(language.getKeyCharacters(0, false));
}
// "00" is a shortcut for the preferred character
else if (digitSequence.equals("00")) {
words.clear();
stem = "";
words.clear();
words.add(settings.getDoubleZeroChar());
}
// emoji
else if (containsOnly1Regex.matcher(digitSequence).matches()) {
words.clear();
stem = "";
words.clear();
if (digitSequence.length() == 1) {
words.addAll(language.getKeyCharacters(1, false));
} else {
@ -190,16 +173,11 @@ public class Predictions {
/**
* dbWordsHandler
* Extracts the words from the Message object, generates extra words, if necessary, then
* notifies the external handler it is now possible to use "getList()".
* If there were no matches in the database, they will be generated based on the "inputWord".
* Callback for when the database has finished loading words. If there were no matches in the database,
* they will be generated based on the "inputWord". After the word list is compiled, it notifies the
* external handler it is now possible to use it with "getList()".
*/
private final Handler dbWordsHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
ArrayList<String> dbWords = msg.getData().getStringArrayList("suggestions");
dbWords = dbWords != null ? dbWords : new ArrayList<>();
private void onDbWords (ArrayList<String> dbWords) {
if (dbWords.size() == 0 && digitSequence.length() > 0) {
emptyDbWarning.emitOnce(language);
dbWords = generatePossibleCompletions(inputWord);
@ -210,9 +188,8 @@ public class Predictions {
suggestMissingWords(generatePossibleStemVariations(dbWords));
suggestMissingWords(dbWords);
onWordsChanged();
onWordsChanged.run();
}
};
/**

View file

@ -46,7 +46,7 @@ public class Characters {
public static ArrayList<String> getEmoji(int level) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return TextEmoticons;
return new ArrayList<>(TextEmoticons);
}
level = (Emoji.size() > level) ? level : Emoji.size() - 1;
@ -59,6 +59,6 @@ public class Characters {
}
}
return availableEmoji.size() > 0 ? availableEmoji : TextEmoticons;
return availableEmoji.size() > 0 ? availableEmoji : new ArrayList<>(TextEmoticons);
}
}

View file

@ -1,9 +1,7 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.Bundle;
import androidx.preference.Preference;
@ -36,7 +34,7 @@ public class ItemLoadDictionary extends ItemClickable {
this.progressBar = progressBar;
this.settings = settings;
loader.setStatusHandler(onDictionaryLoading);
loader.setOnStatusChange(this::onLoadingStatusChange);
if (!progressBar.isCompleted() && !progressBar.isFailed()) {
changeToCancelButton();
@ -46,23 +44,20 @@ public class ItemLoadDictionary extends ItemClickable {
}
private final Handler onDictionaryLoading = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
progressBar.show(msg.getData());
private void onLoadingStatusChange(Bundle status) {
progressBar.show(status);
item.setSummary(progressBar.getTitle() + " " + progressBar.getMessage());
if (progressBar.isCancelled()) {
changeToLoadButton();
} else if (progressBar.isFailed()) {
changeToLoadButton();
UI.toast(context, progressBar.getMessage());
UI.toastFromAsync(context, progressBar.getMessage());
} else if (progressBar.isCompleted()) {
changeToLoadButton();
UI.toast(context, R.string.dictionary_loaded);
UI.toastFromAsync(context, R.string.dictionary_loaded);
}
}
};
@Override

View file

@ -1,9 +1,6 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import androidx.preference.Preference;
@ -28,12 +25,6 @@ public class ItemTruncateAll extends ItemClickable {
this.loader = loader;
}
private final Handler onDictionaryTruncated = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
UI.toast(context, R.string.dictionary_truncated);
}
};
@Override
protected boolean onClick(Preference p) {
@ -42,7 +33,7 @@ public class ItemTruncateAll extends ItemClickable {
loadItem.changeToLoadButton();
}
DictionaryDb.deleteWords(onDictionaryTruncated);
DictionaryDb.deleteWords(() -> UI.toastFromAsync(context, R.string.dictionary_truncated));
return true;
}

View file

@ -1,9 +1,6 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import androidx.preference.Preference;
@ -35,12 +32,6 @@ public class ItemTruncateUnselected extends ItemClickable {
this.loader = loader;
}
private final Handler onDictionaryTruncated = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
UI.toast(context, R.string.dictionary_truncated);
}
};
@Override
protected boolean onClick(Preference p) {
@ -57,7 +48,10 @@ public class ItemTruncateUnselected extends ItemClickable {
}
}
DictionaryDb.deleteWords(onDictionaryTruncated, unselectedLanguageIds);
DictionaryDb.deleteWords(
() -> UI.toastFromAsync(context, R.string.dictionary_truncated),
unselectedLanguageIds
);
return true;
}

View file

@ -2,9 +2,6 @@ package io.github.sspanak.tt9.ui;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.view.View;
import android.widget.EditText;
@ -50,30 +47,27 @@ public class AddWordAct extends AppCompatActivity {
}
private final Handler onAddedWord = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
private void onAddedWord(int statusCode) {
switch (statusCode) {
case 0:
Logger.d("onAddedWord", "Added word: '" + word + "'...");
Logger.d("onAddedWord", "Successfully added word: '" + word + '"');
settings.saveLastWord(word);
break;
case 1:
UI.toastLong(
UI.toastLongFromAsync(
main.getContext(),
getResources().getString(R.string.add_word_exist, word)
);
break;
default:
UI.toastLong(main.getContext(), R.string.error_unexpected);
UI.toastLongFromAsync(main.getContext(), R.string.error_unexpected);
break;
}
finish();
}
};
public void addWord(View v) {
try {
@ -81,7 +75,7 @@ public class AddWordAct extends AppCompatActivity {
word = ((EditText) main.findViewById(R.id.add_word_text)).getText().toString();
Logger.d("addWord", "Attempting to add word: '" + word + "'...");
DictionaryDb.insertWord(onAddedWord, LanguageCollection.getLanguage(lang), word);
DictionaryDb.insertWord(this::onAddedWord, LanguageCollection.getLanguage(lang), word);
} catch (InsertBlankWordException e) {
Logger.e("AddWordAct.addWord", e.getMessage());
UI.toastLong(this, R.string.add_word_blank);
@ -96,6 +90,6 @@ public class AddWordAct extends AppCompatActivity {
public void cancelAddingWord(View v) {
this.finish();
finish();
}
}

View file

@ -2,6 +2,7 @@ package io.github.sspanak.tt9.ui;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import android.widget.Toast;
import io.github.sspanak.tt9.ime.TraditionalT9;
@ -30,15 +31,47 @@ public class UI {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
}
public static void toastFromAsync(Context context, CharSequence msg) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
toast(context, msg);
Looper.loop();
}
public static void toast(Context context, int resourceId) {
Toast.makeText(context, resourceId, Toast.LENGTH_SHORT).show();
}
public static void toastFromAsync(Context context, int resourceId) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
toast(context, resourceId);
Looper.loop();
}
public static void toastLong(Context context, int resourceId) {
Toast.makeText(context, resourceId, Toast.LENGTH_LONG).show();
}
public static void toastLongFromAsync(Context context, int resourceId) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
toast(context, resourceId);
Looper.loop();
}
public static void toastLong(Context context, CharSequence msg) {
Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
}
public static void toastLongFromAsync(Context context, CharSequence msg) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
toastLong(context, msg);
Looper.loop();
}
}