1
0
Fork 0

upgraded Gradle 8.0.2 -> 8.2.2

This commit is contained in:
sspanak 2024-02-10 15:59:51 +02:00 committed by Dimo Karaivanov
parent 041690f8bd
commit 140b8ced08
192 changed files with 162 additions and 187 deletions

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
android:versionCode="5"
android:versionName="git"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.DayNight"
android:supportsRtl="true">
<service android:name="io.github.sspanak.tt9.ime.TraditionalT9" android:permission="android.permission.BIND_INPUT_METHOD"
android:exported="true">
<intent-filter>
<action android:name="android.view.InputMethod"/>
</intent-filter>
<meta-data android:name="android.view.im" android:resource="@xml/method"/>
</service>
<activity android:label="@string/app_name_short" android:name="io.github.sspanak.tt9.preferences.PreferencesActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:excludeFromRecents="true"
android:label="@string/add_word_title"
android:name="io.github.sspanak.tt9.ui.AddWordAct"
android:theme="@style/Theme.AppCompat.DayNight.Dialog.MinWidth"/>
</application>
</manifest>

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

@ -0,0 +1,46 @@
package io.github.sspanak.tt9;
import android.util.Log;
public class Logger {
public static final String TAG_PREFIX = "tt9/";
public static int LEVEL = BuildConfig.DEBUG ? Log.DEBUG : Log.ERROR;
public static boolean isDebugLevel() {
return LEVEL <= Log.DEBUG;
}
public static void enableDebugLevel(boolean yes) {
LEVEL = yes ? Log.DEBUG : Log.ERROR;
}
static public void v(String tag, String msg) {
if (LEVEL <= Log.VERBOSE) {
Log.d(TAG_PREFIX + tag, msg);
}
}
static public void d(String tag, String msg) {
if (LEVEL <= Log.DEBUG) {
Log.d(TAG_PREFIX + tag, msg);
}
}
static public void i(String tag, String msg) {
if (LEVEL <= Log.INFO) {
Log.i(TAG_PREFIX + tag, msg);
}
}
static public void w(String tag, String msg) {
if (LEVEL <= Log.WARN) {
Log.w(TAG_PREFIX + tag, msg);
}
}
static public void e(String tag, String msg) {
if (LEVEL <= Log.ERROR) {
Log.e(TAG_PREFIX + tag, msg);
}
}
}

View file

@ -0,0 +1,54 @@
package io.github.sspanak.tt9;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Pattern;
public class TextTools {
private static final Pattern containsOtherThan1 = Pattern.compile("[02-9]");
private static final Pattern previousIsLetter = Pattern.compile("\\p{L}$");
private static final Pattern nextIsPunctuation = Pattern.compile("^\\p{Punct}");
private static final Pattern nextToWord = Pattern.compile("\\b$");
private static final Pattern startOfSentence = Pattern.compile("(?<!\\.)(^|[.?!؟¿¡])\\s+$");
public static boolean containsOtherThan1(String str) {
return str != null && containsOtherThan1.matcher(str).find();
}
public static boolean isNextToWord(String str) {
return str != null && nextToWord.matcher(str).find();
}
public static boolean isStartOfSentence(String str) {
return str != null && startOfSentence.matcher(str).find();
}
public static boolean nextIsPunctuation(String str) {
return str != null && nextIsPunctuation.matcher(str).find();
}
public static boolean previousIsLetter(String str) {
return str != null && previousIsLetter.matcher(str).find();
}
public static boolean startsWithWhitespace(String str) {
return str != null && !str.isEmpty() && (str.charAt(0) == ' ' || str.charAt(0) == '\n' || str.charAt(0) == '\t');
}
public static boolean startsWithNumber(String str) {
return str != null && !str.isEmpty() && (str.charAt(0) >= '0' && str.charAt(0) <= '9');
}
public static String unixTimestampToISODate(long timestamp) {
if (timestamp < 0) {
return "--";
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
sdf.setTimeZone(TimeZone.getDefault());
return sdf.format(new Date(timestamp));
}
}

View file

@ -0,0 +1,370 @@
package io.github.sspanak.tt9.db;
import android.content.Context;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.os.Handler;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Locale;
import io.github.sspanak.tt9.ConsumerCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.entities.WordBatch;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAlreadyRunningException;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportException;
import io.github.sspanak.tt9.db.sqlite.DeleteOps;
import io.github.sspanak.tt9.db.sqlite.InsertOps;
import io.github.sspanak.tt9.db.sqlite.SQLiteOpener;
import io.github.sspanak.tt9.db.sqlite.Tables;
import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.InvalidLanguageException;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class DictionaryLoader {
private static final String LOG_TAG = "DictionaryLoader";
private static DictionaryLoader self;
private final AssetManager assets;
private final SQLiteOpener sqlite;
private static final Handler asyncHandler = new Handler();
private ConsumerCompat<Bundle> onStatusChange;
private Thread loadThread;
private int currentFile = 0;
private long importStartTime = 0;
private long lastProgressUpdate = 0;
public static DictionaryLoader getInstance(Context context) {
if (self == null) {
self = new DictionaryLoader(context);
}
return self;
}
public DictionaryLoader(Context context) {
assets = context.getAssets();
sqlite = SQLiteOpener.getInstance(context);
}
public void setOnStatusChange(ConsumerCompat<Bundle> callback) {
onStatusChange = callback;
}
private long getImportTime() {
return System.currentTimeMillis() - importStartTime;
}
public void load(ArrayList<Language> languages) throws DictionaryImportAlreadyRunningException {
if (isRunning()) {
throw new DictionaryImportAlreadyRunningException();
}
if (languages.size() == 0) {
Logger.d(LOG_TAG, "Nothing to do");
return;
}
loadThread = new Thread() {
@Override
public void run() {
currentFile = 0;
importStartTime = System.currentTimeMillis();
sendStartMessage(languages.size());
// SQLite does not support parallel queries, so let's import them one by one
for (Language lang : languages) {
if (isInterrupted()) {
break;
}
importAll(lang);
currentFile++;
}
}
};
loadThread.start();
}
public void stop() {
loadThread.interrupt();
}
public boolean isRunning() {
return loadThread != null && loadThread.isAlive();
}
private void importAll(Language language) {
if (language == null) {
Logger.e(LOG_TAG, "Failed loading a dictionary for NULL language.");
sendError(InvalidLanguageException.class.getSimpleName(), -1);
return;
}
try {
long start = System.currentTimeMillis();
float progress = 1;
sqlite.beginTransaction();
Tables.dropIndexes(sqlite.getDb(), language);
sendProgressMessage(language, ++progress, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
logLoadingStep("Indexes dropped", language, start);
start = System.currentTimeMillis();
DeleteOps.delete(sqlite, language.getId());
sendProgressMessage(language, ++progress, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
logLoadingStep("Storage cleared", language, start);
start = System.currentTimeMillis();
int lettersCount = importLetters(language);
sendProgressMessage(language, ++progress, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
logLoadingStep("Letters imported", language, start);
start = System.currentTimeMillis();
InsertOps.restoreCustomWords(sqlite.getDb(), language);
sendProgressMessage(language, ++progress, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
logLoadingStep("Custom words restored", language, start);
start = System.currentTimeMillis();
importWordFile(language, lettersCount, progress, 90);
progress = 90;
sendProgressMessage(language, progress, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
logLoadingStep("Dictionary file imported", language, start);
start = System.currentTimeMillis();
Tables.createPositionIndex(sqlite.getDb(), language);
sendProgressMessage(language, progress + (100f - progress) / 2f, 0);
Tables.createWordIndex(sqlite.getDb(), language);
sendProgressMessage(language, 100, 0);
logLoadingStep("Indexes restored", language, start);
sqlite.finishTransaction();
SlowQueryStats.clear();
} catch (DictionaryImportAbortedException e) {
sqlite.failTransaction();
stop();
Logger.i(LOG_TAG, e.getMessage() + ". File '" + language.getDictionaryFile() + "' not imported.");
} catch (DictionaryImportException e) {
stop();
sqlite.failTransaction();
sendImportError(DictionaryImportException.class.getSimpleName(), language.getId(), e.line, e.word);
Logger.e(
LOG_TAG,
" Invalid word: '" + e.word
+ "' in dictionary: '" + language.getDictionaryFile() + "'"
+ " on line " + e.line
+ " of language '" + language.getName() + "'. "
+ e.getMessage()
);
} catch (Exception | Error e) {
stop();
sqlite.failTransaction();
sendError(e.getClass().getSimpleName(), language.getId());
Logger.e(
LOG_TAG,
"Failed loading dictionary: " + language.getDictionaryFile()
+ " for language '" + language.getName() + "'. "
+ e.getMessage()
);
}
}
private int importLetters(Language language) throws InvalidLanguageCharactersException {
int lettersCount = 0;
boolean isEnglish = language.getLocale().equals(Locale.ENGLISH);
WordBatch letters = new WordBatch(language);
for (int key = 2; key <= 9; key++) {
for (String langChar : language.getKeyCharacters(key, false)) {
langChar = (isEnglish && langChar.equals("i")) ? langChar.toUpperCase(Locale.ENGLISH) : langChar;
letters.add(langChar, 0, key);
lettersCount++;
}
}
saveWordBatch(letters);
return lettersCount;
}
private void importWordFile(Language language, int positionShift, float minProgress, float maxProgress) throws Exception {
int currentLine = 1;
int totalLines = getFileSize(language.getDictionaryFile());
float progressRatio = (maxProgress - minProgress) / totalLines;
WordBatch batch = new WordBatch(language, totalLines);
try (BufferedReader br = new BufferedReader(new InputStreamReader(assets.open(language.getDictionaryFile()), StandardCharsets.UTF_8))) {
for (String line; (line = br.readLine()) != null; currentLine++) {
if (loadThread.isInterrupted()) {
sendProgressMessage(language, 0, 0);
throw new DictionaryImportAbortedException();
}
String[] parts = splitLine(line);
String word = parts[0];
short frequency = getFrequency(parts);
try {
boolean isFinalized = batch.add(word, frequency, currentLine + positionShift);
if (isFinalized && batch.getWords().size() > SettingsStore.DICTIONARY_IMPORT_BATCH_SIZE) {
saveWordBatch(batch);
batch.clear();
}
} catch (InvalidLanguageCharactersException e) {
throw new DictionaryImportException(word, currentLine);
}
if (totalLines > 0) {
sendProgressMessage(language, minProgress + progressRatio * currentLine, SettingsStore.DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME);
}
}
}
saveWordBatch(batch);
InsertOps.insertLanguageMeta(sqlite.getDb(), language.getId());
}
public void saveWordBatch(WordBatch batch) {
InsertOps insertOps = new InsertOps(sqlite.getDb(), batch.getLanguage());
for (int i = 0, end = batch.getWords().size(); i < end; i++) {
insertOps.insertWord(batch.getWords().get(i));
}
for (int i = 0, end = batch.getPositions().size(); i < end; i++) {
insertOps.insertWordPosition(batch.getPositions().get(i));
}
}
private String[] splitLine(String line) {
String[] parts = { line, "" };
// This is faster than String.split() by around 10%, so it's worth having it.
// It runs very often, so any other optimizations are welcome.
for (int i = 0 ; i < line.length(); i++) {
if (line.charAt(i) == ' ') { // the delimiter is TAB
parts[0] = line.substring(0, i);
parts[1] = i < line.length() - 1 ? line.substring(i + 1) : "";
break;
}
}
return parts;
}
private int getFileSize(String filename) {
String sizeFilename = filename + ".size";
try (BufferedReader reader = new BufferedReader(new InputStreamReader(assets.open(sizeFilename), StandardCharsets.UTF_8))) {
return Integer.parseInt(reader.readLine());
} catch (Exception e) {
Logger.w(LOG_TAG, "Could not read the size of: " + filename + " from: " + sizeFilename + ". " + e.getMessage());
return 0;
}
}
private short getFrequency(String[] lineParts) {
try {
return Short.parseShort(lineParts[1]);
} catch (Exception e) {
return 0;
}
}
private void sendStartMessage(int fileCount) {
if (onStatusChange == null) {
Logger.w(LOG_TAG, "Cannot send file count without a status Handler. Ignoring message.");
return;
}
Bundle progressMsg = new Bundle();
progressMsg.putInt("fileCount", fileCount);
progressMsg.putInt("progress", 1);
asyncHandler.post(() -> onStatusChange.accept(progressMsg));
}
private void sendProgressMessage(Language language, float progress, int progressUpdateInterval) {
if (onStatusChange == null) {
Logger.w(LOG_TAG, "Cannot send progress without a status Handler. Ignoring message.");
return;
}
long now = System.currentTimeMillis();
if (now - lastProgressUpdate < progressUpdateInterval) {
return;
}
lastProgressUpdate = now;
Bundle progressMsg = new Bundle();
progressMsg.putInt("languageId", language.getId());
progressMsg.putLong("time", getImportTime());
progressMsg.putInt("progress", Math.round(progress));
progressMsg.putInt("currentFile", currentFile);
asyncHandler.post(() -> onStatusChange.accept(progressMsg));
}
private void sendError(String message, int langId) {
if (onStatusChange == null) {
Logger.w(LOG_TAG, "Cannot send an error without a status Handler. Ignoring message.");
return;
}
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 (onStatusChange == null) {
Logger.w(LOG_TAG, "Cannot send an import error without a status Handler. Ignoring message.");
return;
}
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));
}
private void logLoadingStep(String message, Language language, long time) {
if (Logger.isDebugLevel()) {
Logger.d(LOG_TAG, message + " for language '" + language.getName() + "' in: " + (System.currentTimeMillis() - time) + " ms");
}
}
}

View file

@ -0,0 +1,39 @@
package io.github.sspanak.tt9.db;
import android.app.Activity;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import io.github.sspanak.tt9.Logger;
public class LegacyDb extends SQLiteOpenHelper {
private final String LOG_TAG = getClass().getSimpleName();
private static final String DB_NAME = "t9dict.db";
private static final String TABLE_NAME = "words";
private static boolean isCompleted = false;
public LegacyDb(Activity activity) {
super(activity.getApplicationContext(), DB_NAME, null, 12);
}
public void clear() {
if (isCompleted) {
return;
}
new Thread(() -> {
try (SQLiteDatabase db = getWritableDatabase()) {
db.compileStatement("DROP TABLE " + TABLE_NAME).execute();
Logger.d(LOG_TAG, "SQL Words cleaned successfully.");
} catch (Exception e) {
Logger.d(LOG_TAG, "Assuming no words, because of query error. " + e.getMessage());
} finally {
isCompleted = true;
}
}).start();
}
@Override public void onCreate(SQLiteDatabase db) {}
@Override public void onUpgrade(SQLiteDatabase db, int i, int ii) {}
}

View file

@ -0,0 +1,87 @@
package io.github.sspanak.tt9.db;
import java.util.HashMap;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.TextTools;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class SlowQueryStats {
private static final String LOG_TAG = SlowQueryStats.class.getSimpleName();
private static long firstQueryTime = -1;
private static long maxQueryTime = 0;
private static long totalQueries = 0;
private static long totalQueryTime = 0;
private static final HashMap<String, Integer> slowQueries = new HashMap<>();
private static final HashMap<String, String> resultCache = new HashMap<>();
public static String generateKey(Language language, String sequence, String wordFilter, int minimumWords) {
return language.getId() + "_" + sequence + "_" + wordFilter + "_" + minimumWords;
}
public static void add(String key, int time, String positionsList) {
if (firstQueryTime == -1) {
firstQueryTime = System.currentTimeMillis();
}
maxQueryTime = Math.max(maxQueryTime, time);
totalQueries++;
totalQueryTime += time;
if (time < SettingsStore.SLOW_QUERY_TIME) {
return;
}
slowQueries.put(key, time);
if (!resultCache.containsKey(key)) {
resultCache.put(key, positionsList.replaceAll("-\\d+,", ""));
}
}
public static String getCachedIfSlow(String key) {
Integer queryTime = slowQueries.get(key);
boolean isSlow = queryTime != null && queryTime >= SettingsStore.SLOW_QUERY_TIME;
if (isSlow) {
Logger.d(LOG_TAG, "Loading cached positions for query: " + key);
return resultCache.get(key);
} else {
return null;
}
}
public static String getSummary() {
long slowQueryTotalTime = 0;
for (int time : slowQueries.values()) {
slowQueryTotalTime += time;
}
long averageTime = totalQueries == 0 ? 0 : totalQueryTime / totalQueries;
long slowAverageTime = slowQueries.size() == 0 ? 0 : slowQueryTotalTime / slowQueries.size();
return
"Queries: " + totalQueries + ". Average time: " + averageTime + " ms." +
"\nSlow: " + slowQueries.size() + ". Average time: " + slowAverageTime + " ms." +
"\nSlowest: " + maxQueryTime + " ms." +
"\nFirst: " + TextTools.unixTimestampToISODate(firstQueryTime);
}
public static String getList() {
StringBuilder sb = new StringBuilder();
for (String key : slowQueries.keySet()) {
sb.append(key).append(": ").append(slowQueries.get(key)).append(" ms\n");
}
return sb.toString();
}
public static void clear() {
firstQueryTime = -1;
maxQueryTime = 0;
totalQueries = 0;
totalQueryTime = 0;
slowQueries.clear();
resultCache.clear();
}
}

View file

@ -0,0 +1,259 @@
package io.github.sspanak.tt9.db;
import android.content.Context;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.entities.Word;
import io.github.sspanak.tt9.db.entities.WordList;
import io.github.sspanak.tt9.db.sqlite.DeleteOps;
import io.github.sspanak.tt9.db.sqlite.InsertOps;
import io.github.sspanak.tt9.db.sqlite.ReadOps;
import io.github.sspanak.tt9.db.sqlite.SQLiteOpener;
import io.github.sspanak.tt9.db.sqlite.UpdateOps;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.AddWordAct;
public class WordStore {
private final String LOG_TAG = "sqlite.WordStore";
private static WordStore self;
private SQLiteOpener sqlite = null;
private ReadOps readOps = null;
public WordStore(@NonNull Context context) {
try {
sqlite = SQLiteOpener.getInstance(context);
sqlite.getDb();
readOps = new ReadOps();
} catch (Exception e) {
Logger.w(LOG_TAG, "Database connection failure. All operations will return empty results. " + e.getMessage());
}
self = this;
}
public static synchronized WordStore getInstance(Context context) {
if (self == null) {
context = context == null ? TraditionalT9.getMainContext() : context;
self = new WordStore(context);
}
return self;
}
/**
* Loads words matching and similar to a given digit sequence
* For example: "7655" -> "roll" (exact match), but also: "rolled", "roller", "rolling", ...
* and other similar.
*/
public ArrayList<String> getSimilar(Language language, String sequence, String wordFilter, int minimumWords, int maximumWords) {
if (!checkOrNotify()) {
return new ArrayList<>();
}
if (sequence == null || sequence.length() == 0) {
Logger.w(LOG_TAG, "Attempting to get words for an empty sequence.");
return new ArrayList<>();
}
if (language == null) {
Logger.w(LOG_TAG, "Attempting to get words for NULL language.");
return new ArrayList<>();
}
final int minWords = Math.max(minimumWords, 0);
final int maxWords = Math.max(maximumWords, minWords);
final String filter = wordFilter == null ? "" : wordFilter;
long startTime = System.currentTimeMillis();
String positions = readOps.getSimilarWordPositions(sqlite.getDb(), language, sequence, filter, minWords);
long positionsTime = System.currentTimeMillis() - startTime;
startTime = System.currentTimeMillis();
ArrayList<String> words = readOps.getWords(sqlite.getDb(), language, positions, filter, maxWords, false).toStringList();
long wordsTime = System.currentTimeMillis() - startTime;
printLoadingSummary(sequence, words, positionsTime, wordsTime);
SlowQueryStats.add(SlowQueryStats.generateKey(language, sequence, wordFilter, minWords), (int) (positionsTime + wordsTime), positions);
return words;
}
public boolean exists(Language language) {
return language != null && checkOrNotify() && readOps.exists(sqlite.getDb(), language.getId());
}
public void remove(ArrayList<Integer> languageIds) {
if (!checkOrNotify()) {
return;
}
long start = System.currentTimeMillis();
try {
sqlite.beginTransaction();
for (int langId : languageIds) {
if (readOps.exists(sqlite.getDb(), langId)) {
DeleteOps.delete(sqlite, langId);
}
}
sqlite.finishTransaction();
Logger.d(LOG_TAG, "Deleted " + languageIds.size() + " languages. Time: " + (System.currentTimeMillis() - start) + " ms");
} catch (Exception e) {
sqlite.failTransaction();
Logger.e(LOG_TAG, "Failed deleting languages. " + e.getMessage());
}
}
public int put(Language language, String word) {
if (word == null || word.isEmpty()) {
return AddWordAct.CODE_BLANK_WORD;
}
if (language == null) {
return AddWordAct.CODE_INVALID_LANGUAGE;
}
if (!checkOrNotify()) {
return AddWordAct.CODE_GENERAL_ERROR;
}
try {
if (readOps.exists(sqlite.getDb(), language, word)) {
return AddWordAct.CODE_WORD_EXISTS;
}
String sequence = language.getDigitSequenceForWord(word);
if (InsertOps.insertCustomWord(sqlite.getDb(), language, sequence, word)) {
makeTopWord(language, word, sequence);
} else {
throw new Exception("SQLite INSERT failure.");
}
} catch (Exception e) {
String msg = "Failed inserting word: '" + word + "' for language: " + language.getId() + ". " + e.getMessage();
Logger.e("insertWord", msg);
return AddWordAct.CODE_GENERAL_ERROR;
}
return AddWordAct.CODE_SUCCESS;
}
private boolean checkOrNotify() {
if (sqlite == null || sqlite.getDb() == null) {
Logger.e(LOG_TAG, "No database connection. Cannot query any data.");
return false;
}
return true;
}
public void makeTopWord(@NonNull Language language, @NonNull String word, @NonNull String sequence) {
if (!checkOrNotify() || word.isEmpty() || sequence.isEmpty()) {
return;
}
try {
long start = System.currentTimeMillis();
String topWordPositions = readOps.getWordPositions(sqlite.getDb(), language, sequence, 0, 0, "");
WordList topWords = readOps.getWords(sqlite.getDb(), language, topWordPositions, "", 9999, true);
if (topWords.isEmpty()) {
throw new Exception("No such word");
}
Word topWord = topWords.get(0);
if (topWord.word.toUpperCase(language.getLocale()).equals(word.toUpperCase(language.getLocale()))) {
Logger.d(LOG_TAG, "Word '" + word + "' is already the top word. Time: " + (System.currentTimeMillis() - start) + " ms");
return;
}
int wordPosition = 0;
for (Word tw : topWords) {
if (tw.word.toUpperCase(language.getLocale()).equals(word.toUpperCase(language.getLocale()))) {
wordPosition = tw.position;
break;
}
}
int newTopFrequency = topWord.frequency + 1;
String wordFilter = word.length() == 1 ? word.toLowerCase(language.getLocale()) : null;
if (!UpdateOps.changeFrequency(sqlite.getDb(), language, wordFilter, wordPosition, newTopFrequency)) {
throw new Exception("No such word");
}
if (newTopFrequency > SettingsStore.WORD_FREQUENCY_MAX) {
scheduleNormalization(language);
}
Logger.d(LOG_TAG, "Changed frequency of '" + word + "' to: " + newTopFrequency + ". Time: " + (System.currentTimeMillis() - start) + " ms");
} catch (Exception e) {
Logger.e(LOG_TAG,"Frequency change failed. Word: '" + word + "'. " + e.getMessage());
}
}
public void normalizeNext() {
if (!checkOrNotify()) {
return;
}
long start = System.currentTimeMillis();
try {
sqlite.beginTransaction();
int nextLangId = readOps.getNextInNormalizationQueue(sqlite.getDb());
UpdateOps.normalize(sqlite.getDb(), nextLangId);
sqlite.finishTransaction();
String message = nextLangId > 0 ? "Normalized language: " + nextLangId : "No languages to normalize";
Logger.d(LOG_TAG, message + ". Time: " + (System.currentTimeMillis() - start) + " ms");
} catch (Exception e) {
sqlite.failTransaction();
Logger.e(LOG_TAG, "Normalization failed. " + e.getMessage());
}
}
public void scheduleNormalization(Language language) {
if (language != null && checkOrNotify()) {
UpdateOps.scheduleNormalization(sqlite.getDb(), language);
}
}
private void printLoadingSummary(String sequence, ArrayList<String> words, long positionIndexTime, long wordsTime) {
if (!Logger.isDebugLevel()) {
return;
}
StringBuilder debugText = new StringBuilder("===== Word Loading Summary =====");
debugText
.append("\nWord Count: ").append(words.size())
.append(".\nTime: ").append(positionIndexTime + wordsTime)
.append(" ms (positions: ").append(positionIndexTime)
.append(" ms, words: ").append(wordsTime).append(" ms).");
if (words.isEmpty()) {
debugText.append(" Sequence: ").append(sequence);
} else {
debugText.append("\n").append(words);
}
Logger.d(LOG_TAG, debugText.toString());
}
}

View file

@ -0,0 +1,66 @@
package io.github.sspanak.tt9.db;
import android.content.Context;
import android.os.Handler;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.ConsumerCompat;
import io.github.sspanak.tt9.languages.Language;
public class WordStoreAsync {
private static WordStore store;
private static final Handler asyncHandler = new Handler();
public static synchronized void init(Context context) {
store = WordStore.getInstance(context);
}
public static synchronized void init() {
init(null);
}
private static WordStore getStore() {
init();
return store;
}
public static void normalizeNext() {
new Thread(() -> getStore().normalizeNext()).start();
}
public static void areThereWords(ConsumerCompat<Boolean> notification, Language language) {
new Thread(() -> notification.accept(getStore().exists(language))).start();
}
public static void deleteWords(Runnable notification, @NonNull ArrayList<Integer> languageIds) {
new Thread(() -> {
getStore().remove(languageIds);
notification.run();
}).start();
}
public static void put(ConsumerCompat<Integer> statusHandler, Language language, String word) {
new Thread(() -> statusHandler.accept(getStore().put(language, word))).start();
}
public static void makeTopWord(@NonNull Language language, @NonNull String word, @NonNull String sequence) {
new Thread(() -> getStore().makeTopWord(language, word, sequence)).start();
}
public static void getWords(ConsumerCompat<ArrayList<String>> dataHandler, Language language, String sequence, String filter, int minWords, int maxWords) {
new Thread(() -> asyncHandler.post(() -> dataHandler.accept(
getStore().getSimilar(language, sequence, filter, minWords, maxWords)))
).start();
}
}

View file

@ -0,0 +1,18 @@
package io.github.sspanak.tt9.db.entities;
import androidx.annotation.NonNull;
public class Word {
public int frequency;
public int position;
public String word;
public static Word create(@NonNull String word, int frequency, int position) {
Word w = new Word();
w.frequency = frequency;
w.position = position;
w.word = word;
return w;
}
}

View file

@ -0,0 +1,70 @@
package io.github.sspanak.tt9.db.entities;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.Language;
public class WordBatch {
@NonNull private final Language language;
@NonNull private final ArrayList<Word> words;
@NonNull private final ArrayList<WordPosition> positions;
private WordPosition lastWordPosition;
public WordBatch(@NonNull Language language, int size) {
this.language = language;
words = size > 0 ? new ArrayList<>(size) : new ArrayList<>();
positions = size > 0 ? new ArrayList<>(size) : new ArrayList<>();
}
public WordBatch(@NonNull Language language) {
this(language, 0);
}
public boolean add(@NonNull String word, int frequency, int position) throws InvalidLanguageCharactersException {
words.add(Word.create(word, frequency, position));
if (position == 0) {
return true;
}
String sequence = language.getDigitSequenceForWord(word);
if (position == 1 || lastWordPosition == null) {
lastWordPosition = WordPosition.create(sequence, position);
} else {
lastWordPosition.end = position;
}
if (!sequence.equals(lastWordPosition.sequence)) {
lastWordPosition.end--;
positions.add(lastWordPosition);
lastWordPosition = WordPosition.create(sequence, position);
return true;
}
return false;
}
public void clear() {
words.clear();
positions.clear();
}
@NonNull public Language getLanguage() {
return language;
}
@NonNull public ArrayList<Word> getWords() {
return words;
}
@NonNull public ArrayList<WordPosition> getPositions() {
return positions;
}
}

View file

@ -0,0 +1,17 @@
package io.github.sspanak.tt9.db.entities;
import java.util.ArrayList;
public class WordList extends ArrayList<Word> {
public void add(String word, int frequency, int position) {
add(Word.create(word, frequency, position));
}
public ArrayList<String> toStringList() {
ArrayList<String> list = new ArrayList<>(size());
for (Word word : this) {
list.add(word.word);
}
return list;
}
}

View file

@ -0,0 +1,17 @@
package io.github.sspanak.tt9.db.entities;
import androidx.annotation.NonNull;
public class WordPosition {
public String sequence;
public int start;
public int end;
public static WordPosition create(@NonNull String sequence, int start) {
WordPosition position = new WordPosition();
position.sequence = sequence;
position.start = start;
return position;
}
}

View file

@ -0,0 +1,38 @@
package io.github.sspanak.tt9.db.entities;
import android.database.Cursor;
import androidx.annotation.NonNull;
public class WordPositionsStringBuilder {
public int size = 0;
private final StringBuilder positions = new StringBuilder();
public WordPositionsStringBuilder appendFromDbRanges(Cursor cursor) {
while (cursor.moveToNext()) {
append(cursor.getInt(0), cursor.getInt(1));
}
return this;
}
private void append(int start, int end) {
if (size > 0) {
positions.append(",");
}
positions.append(start);
size++;
for (int position = start + 1; position <= end; position++) {
positions.append(",").append(position);
size++;
}
}
@NonNull
@Override
public String toString() {
return positions.toString();
}
}

View file

@ -0,0 +1,7 @@
package io.github.sspanak.tt9.db.exceptions;
public class DictionaryImportAbortedException extends Exception{
public DictionaryImportAbortedException() {
super("Dictionary import stopped by request.");
}
}

View file

@ -0,0 +1,7 @@
package io.github.sspanak.tt9.db.exceptions;
public class DictionaryImportAlreadyRunningException extends Exception{
public DictionaryImportAlreadyRunningException() {
super("Dictionary import is already running.");
}
}

View file

@ -0,0 +1,12 @@
package io.github.sspanak.tt9.db.exceptions;
public class DictionaryImportException extends Exception {
public final String word;
public final long line;
public DictionaryImportException(String word, long line) {
super("Dictionary import failed");
this.word = word;
this.line = line;
}
}

View file

@ -0,0 +1,63 @@
package io.github.sspanak.tt9.db.sqlite;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDoneException;
import android.database.sqlite.SQLiteStatement;
import androidx.annotation.NonNull;
import java.util.HashMap;
class CompiledQueryCache {
private static CompiledQueryCache self;
private final SQLiteDatabase db;
private final HashMap<Integer, SQLiteStatement> statements = new HashMap<>();
private CompiledQueryCache(@NonNull SQLiteDatabase db) {
this.db = db;
}
CompiledQueryCache execute(String sql) {
get(sql).execute();
return this;
}
SQLiteStatement get(@NonNull String sql) {
SQLiteStatement statement = statements.get(sql.hashCode());
if (statement == null) {
statement = db.compileStatement(sql);
statements.put(sql.hashCode(), statement);
}
return statement;
}
long simpleQueryForLong(String sql, long defaultValue) {
try {
return get(sql).simpleQueryForLong();
} catch (SQLiteDoneException e) {
return defaultValue;
}
}
static CompiledQueryCache getInstance(SQLiteDatabase db) {
if (self == null) {
self = new CompiledQueryCache(db);
}
return self;
}
static CompiledQueryCache execute(SQLiteDatabase db, String sql) {
return getInstance(db).execute(sql);
}
static SQLiteStatement get(SQLiteDatabase db, String sql) {
return getInstance(db).get(sql);
}
static long simpleQueryForLong(SQLiteDatabase db, String sql, long defaultValue) {
return getInstance(db).simpleQueryForLong(sql, defaultValue);
}
}

View file

@ -0,0 +1,10 @@
package io.github.sspanak.tt9.db.sqlite;
import androidx.annotation.NonNull;
public class DeleteOps {
public static void delete(@NonNull SQLiteOpener sqlite, int languageId) {
sqlite.getDb().delete(Tables.getWords(languageId), null, null);
sqlite.getDb().delete(Tables.getWordPositions(languageId), null, null);
}
}

View file

@ -0,0 +1,79 @@
package io.github.sspanak.tt9.db.sqlite;
import android.content.ContentValues;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteStatement;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.db.entities.Word;
import io.github.sspanak.tt9.db.entities.WordPosition;
import io.github.sspanak.tt9.languages.Language;
public class InsertOps {
private final SQLiteStatement insertWordsQuery;
private final SQLiteStatement insertPositionsQuery;
public InsertOps(SQLiteDatabase db, @NonNull Language language) {
// super cache to avoid String concatenation in the dictionary loading loop
insertWordsQuery = CompiledQueryCache.get(db, "INSERT INTO " + Tables.getWords(language.getId()) + " (frequency, position, word) VALUES (?, ?, ?)");
insertPositionsQuery = CompiledQueryCache.get(db, "INSERT INTO " + Tables.getWordPositions(language.getId()) + " (sequence, `start`, `end`) VALUES (?, ?, ?)");
}
public void insertWord(Word word) {
insertWordsQuery.bindLong(1, word.frequency);
insertWordsQuery.bindLong(2, word.position);
insertWordsQuery.bindString(3, word.word);
insertWordsQuery.execute();
}
public void insertWordPosition(WordPosition position) {
insertPositionsQuery.bindString(1, position.sequence);
insertPositionsQuery.bindLong(2, position.start);
insertPositionsQuery.bindLong(3, position.end);
insertPositionsQuery.execute();
}
public static void insertLanguageMeta(@NonNull SQLiteDatabase db, int langId) {
SQLiteStatement query = CompiledQueryCache.get(db, "REPLACE INTO " + Tables.LANGUAGES_META + " (langId) VALUES (?)");
query.bindLong(1, langId);
query.execute();
}
public static boolean insertCustomWord(@NonNull SQLiteDatabase db, @NonNull Language language, @NonNull String sequence, @NonNull String word) {
ContentValues values = new ContentValues();
values.put("langId", language.getId());
values.put("sequence", sequence);
values.put("word", word);
long insertId = db.insert(Tables.CUSTOM_WORDS, null, values);
if (insertId == -1) {
return false;
}
// If the user inserts more than 2^31 custom words, the "position" will overflow and will mess up
// the words table, but realistically it will never happen, so we don't bother preventing it.
values = new ContentValues();
values.put("position", (int)-insertId);
values.put("word", word);
insertId = db.insert(Tables.getWords(language.getId()), null, values);
return insertId != -1;
}
public static void restoreCustomWords(@NonNull SQLiteDatabase db, @NonNull Language language) {
CompiledQueryCache.execute(
db,
"INSERT INTO " + Tables.getWords(language.getId()) + " (position, word) " +
"SELECT -id, word FROM " + Tables.CUSTOM_WORDS + " WHERE langId = " + language.getId()
);
}
}

View file

@ -0,0 +1,224 @@
package io.github.sspanak.tt9.db.sqlite;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDoneException;
import android.database.sqlite.SQLiteStatement;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.SlowQueryStats;
import io.github.sspanak.tt9.db.entities.WordList;
import io.github.sspanak.tt9.db.entities.WordPositionsStringBuilder;
import io.github.sspanak.tt9.languages.Language;
public class ReadOps {
private final String LOG_TAG = "ReadOperations";
/**
* Checks if a word exists in the database for the given language (case-insensitive).
*/
public boolean exists(@NonNull SQLiteDatabase db, @NonNull Language language, @NonNull String word) {
String lowercaseWord = word.toLowerCase(language.getLocale());
String uppercaseWord = word.toUpperCase(language.getLocale());
SQLiteStatement query = CompiledQueryCache.get(db, "SELECT COUNT(*) FROM " + Tables.getWords(language.getId()) + " WHERE word IN(?, ?, ?)");
query.bindString(1, word);
query.bindString(2, lowercaseWord);
query.bindString(3, uppercaseWord);
try {
return query.simpleQueryForLong() > 0;
} catch (SQLiteDoneException e) {
return false;
}
}
/**
* Checks if language exists (has words) in the database.
*/
public boolean exists(@NonNull SQLiteDatabase db, int langId) {
return CompiledQueryCache.simpleQueryForLong(
db,
"SELECT COUNT(*) FROM " + Tables.getWords(langId),
0
) > 0;
}
@NonNull
public WordList getWords(@NonNull SQLiteDatabase db, @NonNull Language language, @NonNull String positions, String filter, int maximumWords, boolean fullOutput) {
if (positions.isEmpty()) {
Logger.d(LOG_TAG, "No word positions. Not searching words.");
return new WordList();
}
String wordsQuery = getWordsQuery(language, positions, filter, maximumWords, fullOutput);
if (wordsQuery.isEmpty()) {
return new WordList();
}
WordList words = new WordList();
try (Cursor cursor = db.rawQuery(wordsQuery, null)) {
while (cursor.moveToNext()) {
words.add(
cursor.getString(0),
fullOutput ? cursor.getInt(1) : 0,
fullOutput ? cursor.getInt(2) : 0
);
}
}
return words;
}
public String getSimilarWordPositions(@NonNull SQLiteDatabase db, @NonNull Language language, @NonNull String sequence, String wordFilter, int minPositions) {
int generations;
switch (sequence.length()) {
case 2:
generations = wordFilter.isEmpty() ? 1 : 10;
break;
case 3:
case 4:
generations = wordFilter.isEmpty() ? 2 : 10;
break;
default:
generations = 10;
break;
}
return getWordPositions(db, language, sequence, generations, minPositions, wordFilter);
}
@NonNull
public String getWordPositions(@NonNull SQLiteDatabase db, @NonNull Language language, @NonNull String sequence, int generations, int minPositions, String wordFilter) {
if (sequence.length() == 1) {
return sequence;
}
WordPositionsStringBuilder positions = new WordPositionsStringBuilder();
String cachedFactoryPositions = SlowQueryStats.getCachedIfSlow(SlowQueryStats.generateKey(language, sequence, wordFilter, minPositions));
if (cachedFactoryPositions != null) {
String customWordPositions = getCustomWordPositions(db, language, sequence, generations);
return customWordPositions.isEmpty() ? cachedFactoryPositions : customWordPositions + "," + cachedFactoryPositions;
}
try (Cursor cursor = db.rawQuery(getPositionsQuery(language, sequence, generations), null)) {
positions.appendFromDbRanges(cursor);
}
if (positions.size < minPositions && generations < Integer.MAX_VALUE) {
Logger.d(LOG_TAG, "Not enough positions: " + positions.size + " < " + minPositions + ". Searching for more.");
try (Cursor cursor = db.rawQuery(getFactoryWordPositionsQuery(language, sequence, Integer.MAX_VALUE), null)) {
positions.appendFromDbRanges(cursor);
}
}
return positions.toString();
}
@NonNull private String getCustomWordPositions(@NonNull SQLiteDatabase db, Language language, String sequence, int generations) {
try (Cursor cursor = db.rawQuery(getCustomWordPositionsQuery(language, sequence, generations), null)) {
return new WordPositionsStringBuilder().appendFromDbRanges(cursor).toString();
}
}
private String getPositionsQuery(@NonNull Language language, @NonNull String sequence, int generations) {
return
"SELECT `start`, `end` FROM ( " +
getFactoryWordPositionsQuery(language, sequence, generations) +
") UNION " +
getCustomWordPositionsQuery(language, sequence, generations);
}
@NonNull private String getFactoryWordPositionsQuery(@NonNull Language language, @NonNull String sequence, int generations) {
StringBuilder sql = new StringBuilder("SELECT `start`, `end` FROM ")
.append(Tables.getWordPositions(language.getId()))
.append(" WHERE ");
if (generations >= 0 && generations < 10) {
sql.append(" sequence IN(").append(sequence);
int lastChild = (int)Math.pow(10, generations) - 1;
for (int seqEnd = 1; seqEnd <= lastChild; seqEnd++) {
if (seqEnd % 10 != 0) {
sql.append(",").append(sequence).append(seqEnd);
}
}
sql.append(")");
} else {
String rangeEnd = generations == 10 ? "9" : "999999";
sql.append(" sequence = ").append(sequence).append(" OR sequence BETWEEN ").append(sequence).append("1 AND ").append(sequence).append(rangeEnd);
sql.append(" ORDER BY `start` ");
sql.append(" LIMIT 100");
}
String positionsSql = sql.toString();
Logger.v(LOG_TAG, "Index SQL: " + positionsSql);
return positionsSql;
}
@NonNull private String getCustomWordPositionsQuery(@NonNull Language language, @NonNull String sequence, int generations) {
String sql = "SELECT -id as `start`, -id as `end` FROM " + Tables.CUSTOM_WORDS +
" WHERE langId = " + language.getId() +
" AND (sequence = " + sequence;
if (generations > 0) {
sql += " OR sequence BETWEEN " + sequence + "1 AND " + sequence + "999999)";
} else {
sql += ")";
}
Logger.v(LOG_TAG, "Custom words SQL: " + sql);
return sql;
}
@NonNull private String getWordsQuery(@NonNull Language language, @NonNull String positions, @NonNull String filter, int maxWords, boolean fullOutput) {
StringBuilder sql = new StringBuilder();
sql
.append("SELECT word");
if (fullOutput) {
sql.append(",frequency,position");
}
sql.append(" FROM ").append(Tables.getWords(language.getId()))
.append(" WHERE position IN(").append(positions).append(")");
if (!filter.isEmpty()) {
sql.append(" AND word LIKE '").append(filter.replaceAll("'", "''")).append("%'");
}
sql
.append(" ORDER BY LENGTH(word), frequency DESC")
.append(" LIMIT ").append(maxWords);
String wordsSql = sql.toString();
Logger.v(LOG_TAG, "Words SQL: " + wordsSql);
return wordsSql;
}
public int getNextInNormalizationQueue(@NonNull SQLiteDatabase db) {
return (int) CompiledQueryCache.simpleQueryForLong(
db,
"SELECT langId FROM " + Tables.LANGUAGES_META + " WHERE normalizationPending = 1 LIMIT 1",
-1
);
}
}

View file

@ -0,0 +1,85 @@
package io.github.sspanak.tt9.db.sqlite;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import java.util.ArrayList;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
public class SQLiteOpener extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "tt9.db";
private static final int DATABASE_VERSION = 1;
private static SQLiteOpener self;
private final ArrayList<Language> allLanguages;
private SQLiteDatabase db;
public SQLiteOpener(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
allLanguages = LanguageCollection.getAll(context);
}
public static SQLiteOpener getInstance(Context context) {
if (self == null) {
self = new SQLiteOpener(context);
}
return self;
}
@Override
public void onCreate(SQLiteDatabase db) {
for (String query : Tables.getCreateQueries(allLanguages)) {
db.execSQL(query);
}
}
@Override
public void onConfigure(SQLiteDatabase db) {
super.onConfigure(db);
setWriteAheadLoggingEnabled(true);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// No migrations as of now
}
public SQLiteDatabase getDb() {
if (db == null) {
db = getWritableDatabase();
}
return db;
}
public void beginTransaction() {
if (db != null) {
db.beginTransactionNonExclusive();
}
}
public void failTransaction() {
if (db != null) {
db.endTransaction();
}
}
public void finishTransaction() {
if (db != null) {
db.setTransactionSuccessful();
db.endTransaction();
}
}
}

View file

@ -0,0 +1,109 @@
package io.github.sspanak.tt9.db.sqlite;
import android.database.sqlite.SQLiteDatabase;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.languages.Language;
public class Tables {
static final String LANGUAGES_META = "languages_meta";
static final String CUSTOM_WORDS = "custom_words";
private static final String POSITIONS_TABLE_BASE_NAME = "word_positions_";
private static final String WORDS_TABLE_BASE_NAME = "words_";
static String getWords(int langId) { return WORDS_TABLE_BASE_NAME + langId; }
static String getWordPositions(int langId) { return POSITIONS_TABLE_BASE_NAME + langId; }
static String[] getCreateQueries(ArrayList<Language> languages) {
int languageCount = languages.size();
String[] queries = new String[languageCount * 2 + 3];
queries[0] = createCustomWords();
queries[1] = createCustomWordsIndex();
queries[2] = createLanguagesMeta();
int queryId = 3;
for (Language language : languages) {
queries[queryId++] = createWordsTable(language.getId());
queries[queryId++] = createWordPositions(language.getId());
}
return queries;
}
public static void createWordIndex(@NonNull SQLiteDatabase db, @NonNull Language language) {
CompiledQueryCache.execute(db, createWordsIndex(language.getId()));
}
public static void createPositionIndex(@NonNull SQLiteDatabase db, @NonNull Language language) {
CompiledQueryCache.execute(db, createWordsPositionsIndex(language.getId()));
}
public static void dropIndexes(@NonNull SQLiteDatabase db, @NonNull Language language) {
CompiledQueryCache
.execute(db, dropWordsIndex(language.getId()))
.execute(dropWordPositionsIndex(language.getId()));
}
private static String createWordsTable(int langId) {
return
"CREATE TABLE IF NOT EXISTS " + getWords(langId) + " (" +
"frequency INTEGER NOT NULL DEFAULT 0, " +
"position INTEGER NOT NULL, " +
"word TEXT NOT NULL" +
")";
}
private static String createWordsIndex(int langId) {
return "CREATE INDEX IF NOT EXISTS idx_position_" + langId + " ON " + getWords(langId) + " (position, word)";
}
private static String dropWordsIndex(int langId) {
return "DROP INDEX IF EXISTS idx_position_" + langId;
}
private static String createWordPositions(int langId) {
return
"CREATE TABLE IF NOT EXISTS " + getWordPositions(langId) + " (" +
"sequence TEXT NOT NULL, " +
"start INTEGER NOT NULL, " +
"end INTEGER NOT NULL" +
")";
}
private static String createWordsPositionsIndex(int langId) {
return "CREATE INDEX IF NOT EXISTS idx_sequence_start_" + langId + " ON " + getWordPositions(langId) + " (sequence, `start`)";
}
private static String dropWordPositionsIndex(int langId) {
return "DROP INDEX IF EXISTS idx_sequence_start_" + langId;
}
private static String createCustomWords() {
return "CREATE TABLE IF NOT EXISTS " + CUSTOM_WORDS + " (" +
"id INTEGER PRIMARY KEY, " +
"langId INTEGER NOT NULL, " +
"sequence TEXT NOT NULL, " +
"word INTEGER NOT NULL " +
")";
}
private static String createCustomWordsIndex() {
return "CREATE INDEX IF NOT EXISTS idx_langId_sequence ON " + CUSTOM_WORDS + " (langId, sequence)";
}
private static String createLanguagesMeta() {
return "CREATE TABLE IF NOT EXISTS " + LANGUAGES_META + " (" +
"langId INTEGER UNIQUE NOT NULL, " +
"normalizationPending INT2 NOT NULL DEFAULT 0 " +
")";
}
}

View file

@ -0,0 +1,59 @@
package io.github.sspanak.tt9.db.sqlite;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteStatement;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class UpdateOps {
private static final String LOG_TAG = UpdateOps.class.getSimpleName();
public static boolean changeFrequency(@NonNull SQLiteDatabase db, @NonNull Language language, String word, int position, int frequency) {
String sql = "UPDATE " + Tables.getWords(language.getId()) + " SET frequency = ? WHERE position = ?";
if (word != null && !word.isEmpty()) {
sql += " AND word = ?";
}
SQLiteStatement query = CompiledQueryCache.get(db, sql);
query.bindLong(1, frequency);
query.bindLong(2, position);
if (word != null && !word.isEmpty()) {
query.bindString(3, word);
}
Logger.v(LOG_TAG, "Change frequency SQL: " + query + "; (" + frequency + ", " + position + ", " + word + ")");
return query.executeUpdateDelete() > 0;
}
public static void normalize(@NonNull SQLiteDatabase db, int langId) {
if (langId <= 0) {
return;
}
SQLiteStatement query = CompiledQueryCache.get(db, "UPDATE " + Tables.getWords(langId) + " SET frequency = frequency / ?");
query.bindLong(1, SettingsStore.WORD_FREQUENCY_NORMALIZATION_DIVIDER);
query.execute();
query = CompiledQueryCache.get(db, "UPDATE " + Tables.LANGUAGES_META + " SET normalizationPending = ? WHERE langId = ?");
query.bindLong(1, 0);
query.bindLong(2, langId);
query.execute();
}
public static void scheduleNormalization(@NonNull SQLiteDatabase db, @NonNull Language language) {
SQLiteStatement query = CompiledQueryCache.get(db, "UPDATE " + Tables.LANGUAGES_META + " SET normalizationPending = ? WHERE langId = ?");
query.bindLong(1, 1);
query.bindLong(2, language.getId());
query.execute();
}
}

View file

@ -0,0 +1,58 @@
package io.github.sspanak.tt9.ime;
import android.content.Context;
import java.util.HashMap;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.UI;
public class EmptyDatabaseWarning {
private static final HashMap<Integer, Long> warningDisplayedTime = new HashMap<>();
private Context context;
private Language language;
public EmptyDatabaseWarning() {
for (Language lang : LanguageCollection.getAll(context)) {
if (!warningDisplayedTime.containsKey(lang.getId())) {
warningDisplayedTime.put(lang.getId(), 0L);
}
}
}
public void emitOnce(Language language) {
context = context == null ? TraditionalT9.getMainContext() : context;
this.language = language;
if (isItTimeAgain(TraditionalT9.getMainContext())) {
WordStoreAsync.areThereWords(this::show, language);
}
}
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());
return lastWarningTime != null && now - lastWarningTime > SettingsStore.DICTIONARY_MISSING_WARNING_INTERVAL;
}
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

@ -0,0 +1,304 @@
package io.github.sspanak.tt9.ime;
import android.inputmethodservice.InputMethodService;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.helpers.Key;
import io.github.sspanak.tt9.preferences.SettingsStore;
abstract class KeyPadHandler extends InputMethodService {
protected SettingsStore settings;
// temporal key handling
private boolean isBackspaceHandled = false;
private int ignoreNextKeyUp = 0;
private int lastKeyCode = 0;
private int keyRepeatCounter = 0;
private int lastNumKeyCode = 0;
private int numKeyRepeatCounter = 0;
/**
* Main initialization of the input method component. Be sure to call to
* super class.
*/
@Override
public void onCreate() {
super.onCreate();
settings = new SettingsStore(getApplicationContext());
onInit();
}
@Override
public boolean onEvaluateInputViewShown() {
super.onEvaluateInputViewShown();
setInputField(getCurrentInputConnection(), getCurrentInputEditorInfo());
return shouldBeVisible();
}
@Override
public boolean onEvaluateFullscreenMode() {
return false;
}
/**
* Called by the framework when your view for creating input needs to be
* generated. This will be called the first time your input method is
* displayed, and every time it needs to be re-created such as due to a
* configuration change.
*/
@Override
public View onCreateInputView() {
return createSoftKeyView();
}
/**
* This is the main point where we do our initialization of the input method
* to begin operating on an application. At this point we have been bound to
* the client, and are now receiving all of the detailed information about
* the target of our edits.
*/
@Override
public void onStartInput(EditorInfo inputField, boolean restarting) {
Logger.d(
"KeyPadHandler",
"===> Start Up; packageName: " + inputField.packageName + " inputType: " + inputField.inputType + " fieldId: " + inputField.fieldId + " fieldName: " + inputField.fieldName + " privateImeOptions: " + inputField.privateImeOptions + " imeOptions: " + inputField.imeOptions + " extras: " + inputField.extras
);
onStart(getCurrentInputConnection(), inputField);
}
@Override
public void onStartInputView(EditorInfo inputField, boolean restarting) {
onStart(getCurrentInputConnection(), inputField);
}
@Override
public void onFinishInputView(boolean finishingInput) {
super.onFinishInputView(finishingInput);
onFinishTyping();
}
/**
* This is called when the user is done editing a field. We can use this to
* reset our state.
*/
@Override
public void onFinishInput() {
super.onFinishInput();
// Logger.d("onFinishInput", "When is this called?");
onStop();
}
/**
* Use this to monitor key events being delivered to the application. We get
* first crack at them, and can either resume them or let them continue to
* the app.
*/
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (shouldBeOff()) {
return false;
}
// Logger.d("onKeyDown", "Key: " + event + " repeat?: " + event.getRepeatCount() + " long-time: " + event.isLongPress());
// "backspace" key must repeat its function when held down, so we handle it in a special way
if (Key.isBackspace(settings, keyCode)) {
if (onBackspace()) {
return isBackspaceHandled = true;
} else {
isBackspaceHandled = false;
}
}
// start tracking key hold
if (Key.isNumber(keyCode)) {
event.startTracking();
return true;
}
else if (Key.isHotkey(settings, -keyCode)) {
event.startTracking();
}
if (Key.isBack(keyCode)) {
return onBack() && super.onKeyDown(keyCode, event);
}
return
Key.isOK(keyCode)
|| handleHotkey(keyCode, true, false, true) // hold a hotkey, handled in onKeyLongPress())
|| handleHotkey(keyCode, false, keyRepeatCounter + 1 > 0, true) // press a hotkey, handled in onKeyUp()
|| Key.isPoundOrStar(keyCode) && onText(String.valueOf((char) event.getUnicodeChar()), true)
|| super.onKeyDown(keyCode, event); // let the system handle the keys we don't care about (usually, the touch "buttons")
}
@Override
public boolean onKeyLongPress(int keyCode, KeyEvent event) {
if (shouldBeOff()) {
return false;
}
// Logger.d("onLongPress", "LONG PRESS: " + keyCode);
if (event.getRepeatCount() > 1) {
return true;
}
ignoreNextKeyUp = keyCode;
if (Key.isNumber(keyCode)) {
numKeyRepeatCounter = 0;
lastNumKeyCode = 0;
return onNumber(Key.codeToNumber(settings, keyCode), true, 0);
} else {
keyRepeatCounter = 0;
lastKeyCode = 0;
}
if (handleHotkey(keyCode, true, false, false)) {
return true;
}
ignoreNextKeyUp = 0;
return false;
}
/**
* Use this to monitor key events being delivered to the application. We get
* first crack at them, and can either resume them or let them continue to
* the app.
*/
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (shouldBeOff()) {
return false;
}
// Logger.d("onKeyUp", "Key: " + keyCode + " repeat?: " + event.getRepeatCount());
if (keyCode == ignoreNextKeyUp) {
// Logger.d("onKeyUp", "Ignored: " + keyCode);
ignoreNextKeyUp = 0;
return true;
}
if (Key.isBackspace(settings, keyCode) && isBackspaceHandled) {
return true;
}
keyRepeatCounter = (lastKeyCode == keyCode) ? keyRepeatCounter + 1 : 0;
lastKeyCode = keyCode;
if (Key.isNumber(keyCode)) {
numKeyRepeatCounter = (lastNumKeyCode == keyCode) ? numKeyRepeatCounter + 1 : 0;
lastNumKeyCode = keyCode;
return onNumber(Key.codeToNumber(settings, keyCode), false, numKeyRepeatCounter);
}
if (Key.isBack(keyCode)) {
return onBack() && super.onKeyUp(keyCode, event);
}
return
Key.isOK(keyCode) && onOK()
|| handleHotkey(keyCode, false, keyRepeatCounter > 0, false)
|| Key.isPoundOrStar(keyCode) && onText(String.valueOf((char) event.getUnicodeChar()), false)
|| super.onKeyUp(keyCode, event); // let the system handle the keys we don't care about (usually, the touch "buttons")
}
private boolean handleHotkey(int keyCode, boolean hold, boolean repeat, boolean validateOnly) {
if (keyCode == settings.getKeyAddWord() * (hold ? -1 : 1)) {
return onKeyAddWord(validateOnly);
}
if (keyCode == settings.getKeyChangeKeyboard() * (hold ? -1 : 1)) {
return onKeyChangeKeyboard(validateOnly);
}
if (keyCode == settings.getKeyFilterClear() * (hold ? -1 : 1)) {
return onKeyFilterClear(validateOnly);
}
if (keyCode == settings.getKeyFilterSuggestions() * (hold ? -1 : 1)) {
return onKeyFilterSuggestions(validateOnly, repeat);
}
if (keyCode == settings.getKeyNextLanguage() * (hold ? -1 : 1)) {
return onKeyNextLanguage(validateOnly);
}
if (keyCode == settings.getKeyNextInputMode() * (hold ? -1 : 1)) {
return onKeyNextInputMode(validateOnly);
}
if (keyCode == settings.getKeyPreviousSuggestion() * (hold ? -1 : 1)) {
return onKeyScrollSuggestion(validateOnly, true);
}
if (keyCode == settings.getKeyNextSuggestion() * (hold ? -1 : 1)) {
return onKeyScrollSuggestion(validateOnly, false);
}
if (keyCode == settings.getKeyShowSettings() * (hold ? -1 : 1)) {
return onKeyShowSettings(validateOnly);
}
return false;
}
protected void resetKeyRepeat() {
numKeyRepeatCounter = 0;
keyRepeatCounter = 0;
lastNumKeyCode = 0;
lastKeyCode = 0;
}
// hardware key handlers
abstract protected boolean onBack();
abstract public boolean onBackspace();
abstract protected boolean onNumber(int key, boolean hold, int repeat);
abstract public boolean onOK();
abstract public boolean onText(String text, boolean validateOnly); // used for "#", "*" and whatnot
// hotkey handlers
abstract protected boolean onKeyAddWord(boolean validateOnly);
abstract protected boolean onKeyChangeKeyboard(boolean validateOnly);
abstract protected boolean onKeyFilterClear(boolean validateOnly);
abstract protected boolean onKeyFilterSuggestions(boolean validateOnly, boolean repeat);
abstract protected boolean onKeyNextLanguage(boolean validateOnly);
abstract protected boolean onKeyNextInputMode(boolean validateOnly);
abstract protected boolean onKeyScrollSuggestion(boolean validateOnly, boolean backward);
abstract protected boolean onKeyShowSettings(boolean validateOnly);
// helpers
abstract protected void onInit();
abstract protected void onStart(InputConnection inputConnection, EditorInfo inputField);
abstract protected void onFinishTyping();
abstract protected void onStop();
abstract protected void setInputField(InputConnection inputConnection, EditorInfo inputField);
// UI
abstract protected View createSoftKeyView();
abstract protected boolean shouldBeVisible();
abstract protected boolean shouldBeOff();
}

View file

@ -0,0 +1,798 @@
package io.github.sspanak.tt9.ime;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.List;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.ime.helpers.AppHacks;
import io.github.sspanak.tt9.ime.helpers.InputModeValidator;
import io.github.sspanak.tt9.ime.helpers.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
import io.github.sspanak.tt9.ui.AddWordAct;
import io.github.sspanak.tt9.ui.UI;
import io.github.sspanak.tt9.ui.main.MainView;
import io.github.sspanak.tt9.ui.tray.StatusBar;
import io.github.sspanak.tt9.ui.tray.SuggestionsBar;
public class TraditionalT9 extends KeyPadHandler {
private InputConnection currentInputConnection = null;
// internal settings/data
@NonNull private AppHacks appHacks = new AppHacks(null,null, null, null);
@NonNull private TextField textField = new TextField(null, null);
@NonNull private InputType inputType = new InputType(null, null);
@NonNull private final Handler autoAcceptHandler = new Handler(Looper.getMainLooper());
@NonNull private final Handler normalizationHandler = new Handler(Looper.getMainLooper());
// input mode
private ArrayList<Integer> allowedInputModes = new ArrayList<>();
@NonNull private InputMode mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH);
// language
protected ArrayList<Integer> mEnabledLanguages;
protected Language mLanguage;
// soft key view
private MainView mainView = null;
private StatusBar statusBar = null;
private SuggestionsBar suggestionBar = null;
private static TraditionalT9 self;
public static Context getMainContext() {
return self.getApplicationContext();
}
public SettingsStore getSettings() {
return settings;
}
public boolean isInputModeNumeric() {
return mInputMode.is123();
}
public boolean isNumericModeStrict() {
return mInputMode.is123() && inputType.isNumeric() && !inputType.isPhoneNumber();
}
public boolean isNumericModeSigned() {
return mInputMode.is123() && inputType.isSignedNumber();
}
public boolean isInputModePhone() {
return mInputMode.is123() && inputType.isPhoneNumber();
}
public int getTextCase() {
return mInputMode.getTextCase();
}
private void validateLanguages() {
mEnabledLanguages = InputModeValidator.validateEnabledLanguages(getMainContext(), mEnabledLanguages);
mLanguage = InputModeValidator.validateLanguage(getMainContext(), mLanguage, mEnabledLanguages);
settings.saveEnabledLanguageIds(mEnabledLanguages);
settings.saveInputLanguage(mLanguage.getId());
}
private void validateFunctionKeys() {
if (settings.areHotkeysInitialized()) {
Hotkeys.setDefault(settings);
}
}
/**
* getInputMode
* Load the last input mode or choose a more appropriate one.
* Some input fields support only numbers or are not suited for predictions (e.g. password fields)
*/
private InputMode getInputMode() {
if (!inputType.isValid() || (inputType.isLimited() && !appHacks.isTermux())) {
return InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_PASSTHROUGH);
}
allowedInputModes = textField.determineInputModes(inputType);
int validModeId = InputModeValidator.validateMode(settings.getInputMode(), allowedInputModes);
return InputMode.getInstance(settings, mLanguage, inputType, validModeId);
}
/**
* determineTextCase
* Restore the last text case or auto-select a new one. If the InputMode supports it, it can change
* the text case based on grammar rules, otherwise we fallback to the input field properties or the
* last saved mode.
*/
private void determineTextCase() {
mInputMode.defaultTextCase();
mInputMode.setTextFieldCase(textField.determineTextCase(inputType));
mInputMode.determineNextWordTextCase(textField.getTextBeforeCursor());
InputModeValidator.validateTextCase(mInputMode, settings.getTextCase());
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
int result = super.onStartCommand(intent, flags, startId);
String message = intent != null ? intent.getStringExtra(AddWordAct.INTENT_FILTER) : null;
if (message != null && !message.isEmpty()) {
forceShowWindowIfHidden();
UI.toastLong(self, message);
}
return result;
}
protected void onInit() {
self = this;
Logger.enableDebugLevel(settings.getDebugLogsEnabled());
WordStoreAsync.init(this);
if (mainView == null) {
mainView = new MainView(this);
initTray();
}
validateFunctionKeys();
}
protected void setInputField(InputConnection connection, EditorInfo field) {
currentInputConnection = connection;
inputType = new InputType(currentInputConnection, field);
textField = new TextField(currentInputConnection, field);
appHacks = new AppHacks(settings, connection, field, textField);
}
private void initTyping() {
// in case we are back from Settings screen, update the language list
mEnabledLanguages = settings.getEnabledLanguageIds();
mLanguage = LanguageCollection.getLanguage(getMainContext(), settings.getInputLanguage());
validateLanguages();
resetKeyRepeat();
setSuggestions(null);
mInputMode = getInputMode();
determineTextCase();
}
private void initTray() {
setInputView(mainView.getView());
statusBar = new StatusBar(mainView.getView());
suggestionBar = new SuggestionsBar(this, mainView.getView());
}
private void setDarkTheme() {
mainView.setDarkTheme(settings.getDarkTheme());
statusBar.setDarkTheme(settings.getDarkTheme());
suggestionBar.setDarkTheme(settings.getDarkTheme());
}
private void initUi() {
if (mainView.createView()) {
initTray();
}
statusBar.setText(mInputMode.toString());
setDarkTheme();
mainView.render();
}
protected void onStart(InputConnection connection, EditorInfo field) {
Logger.enableDebugLevel(settings.getDebugLogsEnabled());
setInputField(connection, field);
initTyping();
if (mInputMode.isPassthrough()) {
// When the input is invalid or simple, let Android handle it.
onStop();
updateInputViewShown();
return;
}
normalizationHandler.removeCallbacksAndMessages(null);
initUi();
updateInputViewShown();
}
protected void onFinishTyping() {
cancelAutoAccept();
mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH);
}
protected void onStop() {
onFinishTyping();
clearSuggestions();
statusBar.setText("--");
normalizationHandler.removeCallbacksAndMessages(null);
normalizationHandler.postDelayed(WordStoreAsync::normalizeNext, SettingsStore.WORD_NORMALIZATION_DELAY);
}
public boolean onBack() {
return settings.getShowSoftNumpad();
}
public boolean onBackspace() {
// 1. Dialer fields seem to handle backspace on their own and we must ignore it,
// otherwise, keyDown race condition occur for all keys.
// 2. Allow the assigned key to function normally, when there is no text (e.g. "Back" navigates back)
// 3. Some app may need special treatment, so let it be.
if (mInputMode.isPassthrough() || !(textField.isThereText() || appHacks.onBackspace(mInputMode))) {
Logger.d("onBackspace", "backspace ignored");
mInputMode.reset();
return false;
}
cancelAutoAccept();
resetKeyRepeat();
if (mInputMode.onBackspace()) {
getSuggestions();
} else {
commitCurrentSuggestion(false);
super.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL);
}
Logger.d("onBackspace", "backspace handled");
return true;
}
/**
* onNumber
*
* @param key Must be a number from 1 to 9, not a "KeyEvent.KEYCODE_X"
* @param hold If "true" we are calling the handler, because the key is being held.
* @param repeat If "true" we are calling the handler, because the key was pressed more than once
* @return boolean
*/
protected boolean onNumber(int key, boolean hold, int repeat) {
cancelAutoAccept();
forceShowWindowIfHidden();
// Automatically accept the previous word, when the next one is a space or punctuation,
// instead of requiring "OK" before that.
// First pass, analyze the incoming key press and decide whether it could be the start of
// a new word.
if (mInputMode.shouldAcceptPreviousSuggestion(key)) {
autoCorrectSpace(acceptIncompleteSuggestion(), false, key);
}
// Auto-adjust the text case before each word, if the InputMode supports it.
if (getComposingText().isEmpty()) {
mInputMode.determineNextWordTextCase(textField.getTextBeforeCursor());
}
if (!mInputMode.onNumber(key, hold, repeat)) {
return false;
}
if (mInputMode.shouldSelectNextSuggestion() && !isSuggestionViewHidden()) {
onKeyScrollSuggestion(false, false);
scheduleAutoAccept(mInputMode.getAutoAcceptTimeout());
} else {
getSuggestions();
}
return true;
}
public boolean onOK() {
cancelAutoAccept();
if (isSuggestionViewHidden()) {
int action = textField.getAction();
return action == TextField.IME_ACTION_ENTER ? appHacks.onEnter() : textField.performAction(action);
}
acceptCurrentSuggestion(KeyEvent.KEYCODE_ENTER);
return true;
}
public boolean onText(String text) { return onText(text, false); }
public boolean onText(String text, boolean validateOnly) {
if (mInputMode.shouldIgnoreText(text)) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
forceShowWindowIfHidden();
// accept the previously typed word (if any)
autoCorrectSpace(acceptIncompleteSuggestion(), false, -1);
// "type" and accept the new word
mInputMode.onAcceptSuggestion(text);
textField.setText(text);
autoCorrectSpace(text, true, -1);
return true;
}
public boolean onKeyAddWord(boolean validateOnly) {
if (!isInputViewShown() || mInputMode.isNumeric()) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
acceptIncompleteSuggestion();
String word = textField.getSurroundingWord(mLanguage);
if (word.isEmpty()) {
UI.toastLong(this, R.string.add_word_no_selection);
} else {
UI.showAddWordDialog(this, mLanguage.getId(), word);
}
return true;
}
public boolean onKeyChangeKeyboard(boolean validateOnly) {
if (!isInputViewShown()) {
return false;
}
if (!validateOnly) {
UI.showChangeKeyboardDialog(this);
}
return true;
}
public boolean onKeyFilterClear(boolean validateOnly) {
if (isSuggestionViewHidden()) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
if (mInputMode.clearWordStem()) {
mInputMode.loadSuggestions(this::getSuggestions, getComposingText());
return true;
}
return false;
}
public boolean onKeyFilterSuggestions(boolean validateOnly, boolean repeat) {
if (isSuggestionViewHidden()) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
String filter;
if (repeat && !suggestionBar.getSuggestion(1).equals("")) {
filter = suggestionBar.getSuggestion(1);
} else {
filter = getComposingText();
}
if (filter.isEmpty()) {
mInputMode.reset();
} else if (mInputMode.setWordStem(filter, repeat)) {
mInputMode.loadSuggestions(this::getSuggestions, filter);
}
return true;
}
public boolean onKeyScrollSuggestion(boolean validateOnly, boolean backward) {
if (isSuggestionViewHidden()) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
suggestionBar.scrollToSuggestion(backward ? -1 : 1);
mInputMode.setWordStem(suggestionBar.getCurrentSuggestion(), true);
setComposingTextWithHighlightedStem(suggestionBar.getCurrentSuggestion());
return true;
}
public boolean onKeyNextLanguage(boolean validateOnly) {
if (mInputMode.isNumeric() || mEnabledLanguages.size() < 2) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
commitCurrentSuggestion(false);
resetKeyRepeat();
nextLang();
mInputMode.changeLanguage(mLanguage);
mInputMode.reset();
statusBar.setText(mInputMode.toString());
mainView.render();
forceShowWindowIfHidden();
return true;
}
public boolean onKeyNextInputMode(boolean validateOnly) {
if (allowedInputModes.size() == 1) {
return false;
}
if (validateOnly) {
return true;
}
scheduleAutoAccept(mInputMode.getAutoAcceptTimeout()); // restart the timer
nextInputMode();
mainView.render();
forceShowWindowIfHidden();
return true;
}
public boolean onKeyShowSettings(boolean validateOnly) {
if (!isInputViewShown()) {
return false;
}
if (validateOnly) {
return true;
}
cancelAutoAccept();
UI.showSettingsScreen(this);
return true;
}
@Override
public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd) {
// Logger.d("onUpdateSelection", "oldSelStart: " + oldSelStart + " oldSelEnd: " + oldSelEnd + " newSelStart: " + newSelStart + " oldSelEnd: " + oldSelEnd + " candidatesStart: " + candidatesStart + " candidatesEnd: " + candidatesEnd);
super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd);
// If the cursor moves while composing a word (usually, because the user has touched the screen outside the word), we must
// end typing end accept the word. Otherwise, the cursor would jump back at the end of the word, after the next key press.
// This is confusing from user perspective, so we want to avoid it.
if (
candidatesStart != -1 && candidatesEnd != -1
&& (newSelStart != candidatesEnd || newSelEnd != candidatesEnd)
&& !suggestionBar.isEmpty()
) {
acceptIncompleteSuggestion();
}
}
private boolean isSuggestionViewHidden() {
return suggestionBar == null || suggestionBar.isEmpty();
}
private boolean scheduleAutoAccept(int delay) {
cancelAutoAccept();
if (suggestionBar.isEmpty()) {
return false;
}
if (delay == 0) {
this.acceptCurrentSuggestion();
return true;
} else if (delay > 0) {
autoAcceptHandler.postDelayed(this::acceptCurrentSuggestion, delay);
}
return false;
}
private void cancelAutoAccept() {
autoAcceptHandler.removeCallbacksAndMessages(null);
}
private void acceptCurrentSuggestion(int fromKey) {
String word = suggestionBar.getCurrentSuggestion();
if (word.isEmpty()) {
return;
}
mInputMode.onAcceptSuggestion(word);
commitCurrentSuggestion();
autoCorrectSpace(word, true, fromKey);
resetKeyRepeat();
}
private void acceptCurrentSuggestion() {
acceptCurrentSuggestion(-1);
}
private String acceptIncompleteSuggestion() {
String currentWord = getComposingText();
mInputMode.onAcceptSuggestion(currentWord);
commitCurrentSuggestion(false);
return currentWord;
}
private void commitCurrentSuggestion() {
commitCurrentSuggestion(true);
}
private void commitCurrentSuggestion(boolean entireSuggestion) {
if (!isSuggestionViewHidden()) {
if (entireSuggestion) {
textField.setComposingText(suggestionBar.getCurrentSuggestion());
}
textField.finishComposingText();
}
setSuggestions(null);
}
private void clearSuggestions() {
setSuggestions(null);
textField.setComposingText("");
textField.finishComposingText();
}
private void getSuggestions() {
mInputMode.loadSuggestions(this::handleSuggestions, suggestionBar.getCurrentSuggestion());
}
private void handleSuggestions() {
// Automatically accept the previous word, without requiring OK. This is similar to what
// Second pass, analyze the available suggestions and decide if combining them with the
// last key press makes up a compound word like: (it)'s, (I)'ve, l'(oiseau), or it is
// just the end of a sentence, like: "word." or "another?"
if (mInputMode.shouldAcceptPreviousSuggestion()) {
String lastComposingText = getComposingText(mInputMode.getSequenceLength() - 1);
commitCurrentSuggestion(false);
mInputMode.onAcceptSuggestion(lastComposingText, true);
autoCorrectSpace(lastComposingText, false, -1);
mInputMode.determineNextWordTextCase(textField.getTextBeforeCursor());
}
// display the word suggestions
setSuggestions(mInputMode.getSuggestions());
// flush the first suggestion, if the InputMode has requested it
if (scheduleAutoAccept(mInputMode.getAutoAcceptTimeout())) {
return;
}
// Otherwise, put the first suggestion in the text field,
// but cut it off to the length of the sequence (how many keys were pressed),
// for a more intuitive experience.
String word = suggestionBar.getCurrentSuggestion();
word = word.substring(0, Math.min(mInputMode.getSequenceLength(), word.length()));
setComposingTextWithHighlightedStem(word);
}
private void setSuggestions(List<String> suggestions) {
setSuggestions(suggestions, 0);
}
private void setSuggestions(List<String> suggestions, int selectedIndex) {
if (suggestionBar != null) {
suggestionBar.setSuggestions(suggestions, selectedIndex);
}
}
private String getComposingText(int maxLength) {
if (maxLength == 0 || suggestionBar.isEmpty()) {
return "";
}
maxLength = maxLength > 0 ? Math.min(maxLength, mInputMode.getSequenceLength()) : mInputMode.getSequenceLength();
String text = suggestionBar.getCurrentSuggestion();
if (text.length() > 0 && text.length() > maxLength) {
text = text.substring(0, maxLength);
}
return text;
}
private String getComposingText() {
return getComposingText(-1);
}
private void refreshComposingText() {
textField.setComposingText(getComposingText());
}
private void setComposingTextWithHighlightedStem(@NonNull String word) {
if (appHacks.setComposingTextWithHighlightedStem(word)) {
Logger.w("highlightComposingText", "Defective text field detected! Text highlighting disabled.");
} else {
textField.setComposingTextWithHighlightedStem(word, mInputMode);
}
}
private void nextInputMode() {
if (mInputMode.isPassthrough()) {
return;
} else if (allowedInputModes.size() == 1 && allowedInputModes.contains(InputMode.MODE_123)) {
mInputMode = !mInputMode.is123() ? InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_123) : mInputMode;
}
// when typing a word or viewing scrolling the suggestions, only change the case
else if (!isSuggestionViewHidden()) {
String currentSuggestionBefore = getComposingText();
// When we are in AUTO mode and the dictionary word is in uppercase,
// the mode would switch to UPPERCASE, but visually, the word would not change.
// This is why we retry, until there is a visual change.
for (int retries = 0; retries < 2 && mLanguage.hasUpperCase(); retries++) {
mInputMode.nextTextCase();
setSuggestions(mInputMode.getSuggestions(), suggestionBar.getCurrentIndex());
refreshComposingText();
if (!currentSuggestionBefore.equals(getComposingText())) {
break;
}
}
}
// make "abc" and "ABC" separate modes from user perspective
else if (mInputMode.isABC() && mInputMode.getTextCase() == InputMode.CASE_LOWER && mLanguage.hasUpperCase()) {
mInputMode.nextTextCase();
} else {
int nextModeIndex = (allowedInputModes.indexOf(mInputMode.getId()) + 1) % allowedInputModes.size();
mInputMode = InputMode.getInstance(settings, mLanguage, inputType, allowedInputModes.get(nextModeIndex));
mInputMode.setTextFieldCase(textField.determineTextCase(inputType));
mInputMode.determineNextWordTextCase(textField.getTextBeforeCursor());
resetKeyRepeat();
}
// save the settings for the next time
settings.saveInputMode(mInputMode.getId());
settings.saveTextCase(mInputMode.getTextCase());
statusBar.setText(mInputMode.toString());
}
private void nextLang() {
// select the next language
int previous = mEnabledLanguages.indexOf(mLanguage.getId());
int next = (previous + 1) % mEnabledLanguages.size();
mLanguage = LanguageCollection.getLanguage(getMainContext(), mEnabledLanguages.get(next));
validateLanguages();
// save it for the next time
settings.saveInputLanguage(mLanguage.getId());
}
private void autoCorrectSpace(String currentWord, boolean isWordAcceptedManually, int nextKey) {
if (mInputMode.shouldDeletePrecedingSpace(inputType)) {
textField.deletePrecedingSpace(currentWord);
}
if (mInputMode.shouldAddAutoSpace(inputType, textField, isWordAcceptedManually, nextKey)) {
textField.setText(" ");
}
}
/**
* createSoftKeyView
* Generates the actual UI of TT9.
*/
protected View createSoftKeyView() {
mainView.forceCreateView();
initTray();
setDarkTheme();
return mainView.getView();
}
/**
* forceShowWindowIfHidden
* Some applications may hide our window and it remains invisible until the screen is touched or OK is pressed.
* This is fine for touchscreen keyboards, but the hardware keyboard allows typing even when the window and the suggestions
* are invisible. This function forces the InputMethodManager to show our window.
*/
protected void forceShowWindowIfHidden() {
if (mInputMode.isPassthrough() || isInputViewShown()) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
requestShowSelf(InputMethodManager.SHOW_IMPLICIT);
} else {
showWindow(true);
}
}
@Override
protected boolean shouldBeVisible() {
return !getInputMode().isPassthrough();
}
@Override
protected boolean shouldBeOff() {
return currentInputConnection == null || mInputMode.isPassthrough();
}
}

View file

@ -0,0 +1,198 @@
package io.github.sspanak.tt9.ime.helpers;
import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class AppHacks {
private final EditorInfo editorInfo;
private final InputConnection inputConnection;
private final SettingsStore settings;
private final TextField textField;
public AppHacks(SettingsStore settings, InputConnection inputConnection, EditorInfo inputField, TextField textField) {
this.editorInfo = inputField;
this.inputConnection = inputConnection;
this.settings = settings;
this.textField = textField;
}
/**
* isKindleInvertedTextField
* When sharing a document to the Amazon Kindle app. It displays a screen where one could edit the title and the author of the
* document. These two fields do not support SpannableString, which is used for suggestion highlighting. When they receive one
* weird side effects occur. Nevertheless, all other text fields in the app are fine, so we detect only these two particular ones.
*/
private boolean isKindleInvertedTextField() {
return isAppField("com.amazon.kindle", EditorInfo.TYPE_CLASS_TEXT);
}
/**
* isTermux
* Termux is a terminal emulator and it naturally has a text input, but it incorrectly introduces itself as having a NULL input,
* instead of a plain text input. However NULL inputs are usually, buttons and dropdown menus, which indeed can not read text
* and are ignored by TT9 by default. In order not to ignore Termux, we need this.
*/
public boolean isTermux() {
return isAppField("com.termux", EditorInfo.TYPE_NULL) && editorInfo.fieldId > 0;
}
/**
* isMessenger
* Facebook Messenger has flaky support for sending messages. To fix that, we detect the chat input field and send the appropriate
* key codes to it. See "onFbMessengerEnter()" for info how the hack works.
*/
private boolean isMessenger() {
return isAppField(
"com.facebook.orca",
EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE | EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES
);
}
/**
* isAppField
* Detects a particular input field of a particular application.
*/
private boolean isAppField(String appPackageName, int fieldSpec) {
return
editorInfo != null
&& ((editorInfo.inputType & fieldSpec) == fieldSpec)
&& editorInfo.packageName.equals(appPackageName);
}
/**
* setComposingTextWithHighlightedStem
* A compatibility function for text fields that do not support SpannableString. Effectively disables highlighting.
*/
public boolean setComposingTextWithHighlightedStem(@NonNull String word) {
if (isKindleInvertedTextField()) {
textField.setComposingText(word);
return true;
}
return false;
}
/**
* onBackspace
* Performs extra Backspace operations and returns "false", or completely replaces Backspace and returns "true". When "true" is
* returned, you must not attempt to delete text. This function has already done everything necessary.
*/
public boolean onBackspace(InputMode inputMode) {
if (isKindleInvertedTextField()) {
inputMode.clearWordStem();
return true;
} else if (isTermux()) {
return settings.getKeyBackspace() != KeyEvent.KEYCODE_BACK;
}
return false;
}
/**
* onEnter
* Tries to guess and send the correct confirmation key code or sequence of key codes, depending on the connected application
* and input field. On invalid connection or field, it does nothing.
* This hack applies to all applications, not only selected ones.
*/
public boolean onEnter() {
if (isTermux()) {
sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER);
return true;
} else if (isMessenger()) {
return onEnterFbMessenger();
}
return onEnterDefault();
}
/**
* onEnterDefault
* This is the default "ENTER" routine for most applications that support send-with-enter functionality. It will attempt to
* guess and send the correct confirmation key code, be it "ENTER" or "DPAD_CENTER".
* On invalid textField, it does nothing.
*/
private boolean onEnterDefault() {
if (textField == null) {
return false;
}
String oldText = textField.getTextBeforeCursor() + textField.getTextAfterCursor();
sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_CENTER);
// If there is no text, there is nothing to send, so there is no need to attempt any hacks.
// We just pass through DPAD_CENTER and finish as if the key press was handled by the system.
if (oldText.isEmpty()) {
return true;
}
try {
// In Android there is no strictly defined confirmation key, hence DPAD_CENTER may have done nothing.
// If so, send an alternative key code as a final resort.
Thread.sleep(80);
String newText = textField.getTextBeforeCursor() + textField.getTextAfterCursor();
if (newText.equals(oldText)) {
sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER);
}
} catch (InterruptedException e) {
// This thread got interrupted. Assume it's because the connected application has taken an action
// after receiving DPAD_CENTER, so we don't need to do anything else.
return true;
}
return true;
}
/**
* onEnterFbMessenger
* Once we have detected the chat message field we apply the appropriate key combo to send the message.
*/
private boolean onEnterFbMessenger() {
if (textField == null) {
return false;
}
// in case the setting is disabled, just type a new line as one would expect
if (!settings.getFbMessengerHack()) {
inputConnection.commitText("\n", 1);
return true;
}
// do not send any commands if the user has not typed anything or the field is invalid
if (!textField.isThereText()) {
return false;
}
if (isMessenger()) {
// Messenger responds only to ENTER, but not DPAD_CENTER, so we make sure to send the correct code,
// no matter how the hardware key is implemented.
sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER);
}
return true;
}
private void sendDownUpKeyEvents(int keyCode) {
if (inputConnection != null) {
inputConnection.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
inputConnection.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
}
}

View file

@ -0,0 +1,27 @@
package io.github.sspanak.tt9.ime.helpers;
import android.content.Context;
import android.view.inputmethod.InputMethodInfo;
import android.view.inputmethod.InputMethodManager;
public class GlobalKeyboardSettings {
private final InputMethodManager inputManager;
private final String packageName;
public GlobalKeyboardSettings(Context context, InputMethodManager inputManager) {
this.inputManager = inputManager;
packageName = context.getPackageName();
}
public boolean isTT9Enabled() {
for (final InputMethodInfo imeInfo : inputManager.getEnabledInputMethodList()) {
if (packageName.equals(imeInfo.getPackageName())) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,66 @@
package io.github.sspanak.tt9.ime.helpers;
import android.content.Context;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
public class InputModeValidator {
public static ArrayList<Integer> validateEnabledLanguages(Context context, ArrayList<Integer> enabledLanguageIds) {
ArrayList<Language> validLanguages = LanguageCollection.getAll(context, enabledLanguageIds);
ArrayList<Integer> validLanguageIds = new ArrayList<>();
for (Language lang : validLanguages) {
validLanguageIds.add(lang.getId());
}
if (validLanguageIds.size() == 0) {
validLanguageIds.add(LanguageCollection.getDefault(context).getId());
Logger.e("validateEnabledLanguages", "The language list seems to be corrupted. Resetting to first language only.");
}
return validLanguageIds;
}
public static Language validateLanguage(Context context, Language language, ArrayList<Integer> validLanguageIds) {
if (language != null && validLanguageIds.contains(language.getId())) {
return language;
}
String error = language != null ? "Language: " + language.getId() + " is not enabled." : "Invalid language.";
Language validLanguage = LanguageCollection.getLanguage(context, validLanguageIds.get(0));
validLanguage = validLanguage != null ? validLanguage : LanguageCollection.getDefault(context);
Logger.w("validateLanguage", error + " Enforcing language: " + validLanguage.getId());
return validLanguage;
}
public static int validateMode(int oldModeId, ArrayList<Integer> allowedModes) {
int newModeId = InputMode.MODE_123;
if (allowedModes.contains(oldModeId)) {
newModeId = oldModeId;
} else if (allowedModes.contains(InputMode.MODE_ABC)) {
newModeId = InputMode.MODE_ABC;
} else if (allowedModes.size() > 0) {
newModeId = allowedModes.get(0);
}
if (newModeId != oldModeId) {
Logger.w("validateMode", "Invalid input mode: " + oldModeId + " Enforcing: " + newModeId);
}
return newModeId;
}
public static void validateTextCase(InputMode inputMode, int newTextCase) {
if (!inputMode.setTextCase(newTextCase)) {
inputMode.defaultTextCase();
Logger.w("validateTextCase", "Invalid text case: " + newTextCase + " Enforcing: " + inputMode.getTextCase());
}
}
}

View file

@ -0,0 +1,120 @@
package io.github.sspanak.tt9.ime.helpers;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
public class InputType {
private final InputConnection connection;
private final EditorInfo field;
public InputType(InputConnection inputConnection, EditorInfo inputField) {
connection = inputConnection;
field = inputField;
}
public boolean isValid() {
return field != null && connection != null;
}
/**
* isLimited
* Special or limited input type means the input connection is not rich,
* or it can not process or show things like candidate text, nor retrieve the current text.
* <p>
* More info: <a href="https://developer.android.com/reference/android/text/InputType#TYPE_NULL">android docs</a>.
*/
public boolean isLimited() {
return field != null && field.inputType == android.text.InputType.TYPE_NULL;
}
/**
* isSpecialNumeric
* Calculator and Dialer fields seem to take care of numbers and backspace on their own,
* so we need to be aware of them.
* <p>
* NOTE: A Dialer field is not the same as Phone field. Dialer is where you
* actually dial and call a phone number. While the Phone field is a text
* field in any app or a webpage, intended for typing phone numbers.
* <p>
* More info: <a href="https://github.com/sspanak/tt9/issues/46">in this Github issue</a>
* and <a href="https://github.com/sspanak/tt9/pull/326">the PR about calculators</a>.
*/
public boolean isSpecialNumeric() {
return
isPhoneNumber() && field.packageName.equals("com.android.dialer")
|| isNumeric() && field.packageName.contains("com.android.calculator");
}
public boolean isPhoneNumber() {
return
field != null
&& (field.inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_PHONE;
}
public boolean isNumeric() {
return
field != null
&& (field.inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_NUMBER;
}
public boolean isDecimal() {
return
isNumeric()
&& (field.inputType & android.text.InputType.TYPE_MASK_FLAGS) == android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL;
}
public boolean isSignedNumber() {
return
isNumeric()
&& (field.inputType & android.text.InputType.TYPE_MASK_FLAGS) == android.text.InputType.TYPE_NUMBER_FLAG_SIGNED;
}
public boolean isEmail() {
if (field == null) {
return false;
}
int variation = field.inputType & android.text.InputType.TYPE_MASK_VARIATION;
return
variation == android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
|| variation == android.text.InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS;
}
public boolean isPassword() {
if (field == null) {
return false;
}
int variation = field.inputType & android.text.InputType.TYPE_MASK_VARIATION;
return
variation == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|| variation == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|| variation == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD;
}
boolean isPersonName() {
return field != null && (field.inputType & android.text.InputType.TYPE_MASK_VARIATION) == android.text.InputType.TYPE_TEXT_VARIATION_PERSON_NAME;
}
public boolean isSpecialized() {
return isEmail() || isPassword() || isUri();
}
private boolean isUri() {
return field != null && (field.inputType & android.text.InputType.TYPE_MASK_VARIATION) == android.text.InputType.TYPE_TEXT_VARIATION_URI;
}
}

View file

@ -0,0 +1,97 @@
package io.github.sspanak.tt9.ime.helpers;
import android.view.KeyEvent;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class Key {
public static boolean isBackspace(SettingsStore settings, int keyCode) {
return
keyCode == KeyEvent.KEYCODE_DEL
|| keyCode == KeyEvent.KEYCODE_CLEAR
|| keyCode == settings.getKeyBackspace();
}
public static boolean isNumber(int keyCode) {
return
(keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9)
|| (keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && keyCode <= KeyEvent.KEYCODE_NUMPAD_9);
}
public static boolean isHotkey(SettingsStore settings, int keyCode) {
return keyCode == settings.getKeyAddWord()
|| keyCode == settings.getKeyBackspace()
|| keyCode == settings.getKeyChangeKeyboard()
|| keyCode == settings.getKeyFilterClear()
|| keyCode == settings.getKeyFilterSuggestions()
|| keyCode == settings.getKeyPreviousSuggestion()
|| keyCode == settings.getKeyNextSuggestion()
|| keyCode == settings.getKeyNextInputMode()
|| keyCode == settings.getKeyNextLanguage()
|| keyCode == settings.getKeyShowSettings();
}
public static boolean isBack(int keyCode) { return keyCode == KeyEvent.KEYCODE_BACK; }
public static boolean isPoundOrStar(int keyCode) {
return keyCode == KeyEvent.KEYCODE_POUND || keyCode == KeyEvent.KEYCODE_STAR;
}
public static boolean isOK(int keyCode) {
return
keyCode == KeyEvent.KEYCODE_DPAD_CENTER
|| keyCode == KeyEvent.KEYCODE_ENTER
|| keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER;
}
public static int codeToNumber(SettingsStore settings, int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_0:
case KeyEvent.KEYCODE_NUMPAD_0:
return 0;
case KeyEvent.KEYCODE_1:
case KeyEvent.KEYCODE_NUMPAD_1:
return settings.getUpsideDownKeys() ? 7 : 1;
case KeyEvent.KEYCODE_2:
case KeyEvent.KEYCODE_NUMPAD_2:
return settings.getUpsideDownKeys() ? 8 : 2;
case KeyEvent.KEYCODE_3:
case KeyEvent.KEYCODE_NUMPAD_3:
return settings.getUpsideDownKeys() ? 9 : 3;
case KeyEvent.KEYCODE_4:
case KeyEvent.KEYCODE_NUMPAD_4:
return 4;
case KeyEvent.KEYCODE_5:
case KeyEvent.KEYCODE_NUMPAD_5:
return 5;
case KeyEvent.KEYCODE_6:
case KeyEvent.KEYCODE_NUMPAD_6:
return 6;
case KeyEvent.KEYCODE_7:
case KeyEvent.KEYCODE_NUMPAD_7:
return settings.getUpsideDownKeys() ? 1 : 7;
case KeyEvent.KEYCODE_8:
case KeyEvent.KEYCODE_NUMPAD_8:
return settings.getUpsideDownKeys() ? 2 : 8;
case KeyEvent.KEYCODE_9:
case KeyEvent.KEYCODE_NUMPAD_9:
return settings.getUpsideDownKeys() ? 3 : 9;
default:
return -1;
}
}
public static int numberToCode(int number) {
if (number >= 0 && number <= 9) {
return KeyEvent.KEYCODE_0 + number;
} else {
return -1;
}
}
}

View file

@ -0,0 +1,363 @@
package io.github.sspanak.tt9.ime.helpers;
import android.graphics.Typeface;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.StyleSpan;
import android.text.style.UnderlineSpan;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
public class TextField {
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 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 EditorInfo field;
public TextField(InputConnection inputConnection, EditorInfo inputField) {
connection = inputConnection;
field = inputField;
}
public boolean isThereText() {
if (connection == null) {
return false;
}
ExtractedText extractedText = connection.getExtractedText(new ExtractedTextRequest(), 0);
return extractedText != null && extractedText.text.length() > 0;
}
/**
* getPreviousChar
* Gets the character before the cursor.
*/
public String getPreviousChars(int numberOfChars) {
CharSequence character = connection != null ? connection.getTextBeforeCursor(numberOfChars, 0) : null;
return character != null ? character.toString() : "";
}
/**
* getNextChar
* Gets the character after the cursor.
*/
public String getNextChars(int numberOfChars) {
CharSequence character = connection != null ? connection.getTextAfterCursor(numberOfChars, 0) : null;
return character != null ? character.toString() : "";
}
/**
* determineInputModes
* Determine the typing mode based on the input field being edited. Returns an ArrayList of the allowed modes.
*
* @return ArrayList<SettingsStore.MODE_ABC | SettingsStore.MODE_123 | SettingsStore.MODE_PREDICTIVE>
*/
public ArrayList<Integer> determineInputModes(InputType inputType) {
ArrayList<Integer> allowedModes = new ArrayList<>();
if (field == null) {
allowedModes.add(InputMode.MODE_123);
return allowedModes;
}
// Calculators (only 0-9 and math) and Dialer (0-9, "#" and "*") fields
// handle all input themselves, so we are supposed to pass through all key presses.
// Note: A Dialer field is not a Phone number field.
if (inputType.isSpecialNumeric()) {
allowedModes.add(InputMode.MODE_PASSTHROUGH);
return allowedModes;
}
switch (field.inputType & android.text.InputType.TYPE_MASK_CLASS) {
case android.text.InputType.TYPE_CLASS_NUMBER:
case android.text.InputType.TYPE_CLASS_DATETIME:
case android.text.InputType.TYPE_CLASS_PHONE:
// Numbers, dates and phone numbers default to the numeric keyboard,
// with no extra features.
allowedModes.add(InputMode.MODE_123);
return allowedModes;
case android.text.InputType.TYPE_CLASS_TEXT:
// This is general text editing. We will default to the
// normal alphabetic keyboard, and assume that we should
// be doing predictive text (showing candidates as the
// user types).
if (!inputType.isPassword()) {
allowedModes.add(InputMode.MODE_PREDICTIVE);
}
// fallthrough to add ABC and 123 modes
default:
// For all unknown input types, default to the alphabetic
// keyboard with no special features.
allowedModes.add(InputMode.MODE_123);
allowedModes.add(InputMode.MODE_ABC);
return allowedModes;
}
}
/**
* Helper to update the shift state of our keyboard based on the initial
* editor state.
*/
public int determineTextCase(InputType inputType) {
if (connection == null || field == null || field.inputType == android.text.InputType.TYPE_NULL) {
return InputMode.CASE_UNDEFINED;
}
if (inputType.isSpecialized()) {
return InputMode.CASE_LOWER;
}
if (inputType.isPersonName()) {
return InputMode.CASE_CAPITALIZE;
}
switch (field.inputType & android.text.InputType.TYPE_MASK_FLAGS) {
case android.text.InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS:
return InputMode.CASE_UPPER;
case android.text.InputType.TYPE_TEXT_FLAG_CAP_WORDS:
return InputMode.CASE_CAPITALIZE;
}
return InputMode.CASE_UNDEFINED;
}
/**
* getTextBeforeCursor
* A simplified helper that return up to 50 characters before the cursor and "just works".
*/
public String getTextBeforeCursor() {
if (connection == null) {
return "";
}
CharSequence before = connection.getTextBeforeCursor(50, 0);
return before != null ? before.toString() : "";
}
/**
* getTextBeforeCursor
* A simplified helper that return up to 50 characters after the cursor and "just works".
*/
public String getTextAfterCursor() {
if (connection == null) {
return "";
}
CharSequence before = connection.getTextAfterCursor(50, 0);
return before != null ? before.toString() : "";
}
/**
* getSurroundingWord
* Returns the word next or around the cursor. Scanning length is up to 50 chars in each direction.
*/
@NonNull public String getSurroundingWord(Language language) {
Matcher before;
Matcher after;
if (language != null && (language.isHebrew() || language.isUkrainian())) {
// Hebrew and Ukrainian use 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) : "");
}
/**
* deletePrecedingSpace
* Deletes the preceding space before the given word. The word must be before the cursor.
* No action is taken when there is double space or when it's the beginning of the text field.
*/
public void deletePrecedingSpace(String word) {
if (connection == null) {
return;
}
String searchText = " " + word;
connection.beginBatchEdit();
CharSequence beforeText = connection.getTextBeforeCursor(searchText.length() + 1, 0);
if (
beforeText == null
|| beforeText.length() < searchText.length() + 1
|| beforeText.charAt(1) != ' ' // preceding char must be " "
|| beforeText.charAt(0) == ' ' // but do nothing when there is double space
) {
connection.endBatchEdit();
return;
}
connection.deleteSurroundingText(searchText.length(), 0);
connection.commitText(word, 1);
connection.endBatchEdit();
}
/**
* setText
* A fail-safe setter that appends text to the field, ignoring NULL input.
*/
public void setText(String text) {
if (text != null && connection != null) {
connection.commitText(text, 1);
}
}
/**
* setComposingText
* A fail-safe setter for composing text, which ignores NULL input.
*/
public void setComposingText(CharSequence text, int position) {
if (text != null && connection != null) {
connection.setComposingText(text, position);
}
}
public void setComposingText(CharSequence text) { setComposingText(text, 1); }
/**
* setComposingTextWithHighlightedStem
*
* Sets the composing text, but makes the "stem" substring bold. If "highlightMore" is true,
* the "stem" part will be in bold and italic.
*/
public void setComposingTextWithHighlightedStem(CharSequence word, String stem, boolean highlightMore) {
setComposingText(
stem.length() > 0 ? highlightText(word, 0, stem.length(), highlightMore) : word
);
}
public void setComposingTextWithHighlightedStem(CharSequence word, InputMode inputMode) {
setComposingTextWithHighlightedStem(word, inputMode.getWordStem(), inputMode.isStemFilterFuzzy());
}
/**
* finishComposingText
* Finish composing text or do nothing if the text field is invalid.
*/
public void finishComposingText() {
if (connection != null) {
connection.finishComposingText();
}
}
/**
* highlightText
* Makes the characters from "start" to "end" bold. If "highlightMore" is true,
* the text will be in bold and italic.
*/
private CharSequence highlightText(CharSequence word, int start, int end, boolean highlightMore) {
if (end <= start || start < 0) {
Logger.w("tt9.util.highlightComposingText", "Cannot highlight invalid composing text range: [" + start + ", " + end + "]");
return word;
}
// nothing to highlight in an empty word or if the target is beyond the last letter
if (word == null || word.length() == 0 || word.length() <= start) {
return word;
}
SpannableString styledWord = new SpannableString(word);
// default underline style
styledWord.setSpan(new UnderlineSpan(), 0, word.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
// highlight the requested range
styledWord.setSpan(
new StyleSpan(Typeface.BOLD),
start,
Math.min(word.length(), end),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
if (highlightMore) {
styledWord.setSpan(
new StyleSpan(Typeface.BOLD_ITALIC),
start,
Math.min(word.length(), end),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
return styledWord;
}
/**
* getAction
* Returns the most appropriate action for the "OK" key. It could be "send", "act as ENTER key", "go (to URL)" and so on.
*/
public int getAction() {
if (field == null) {
return EditorInfo.IME_ACTION_NONE;
}
if (field.actionId == EditorInfo.IME_ACTION_DONE) {
return IME_ACTION_ENTER;
} else if (field.actionId > 0) {
return field.actionId;
}
int standardAction = field.imeOptions & (EditorInfo.IME_MASK_ACTION | EditorInfo.IME_FLAG_NO_ENTER_ACTION);
switch (standardAction) {
case EditorInfo.IME_ACTION_GO:
case EditorInfo.IME_ACTION_NEXT:
case EditorInfo.IME_ACTION_PREVIOUS:
case EditorInfo.IME_ACTION_SEARCH:
case EditorInfo.IME_ACTION_SEND:
return standardAction;
default:
return IME_ACTION_ENTER;
}
}
/**
* performAction
* Sends an action ID to the connected application. Usually, the action is determined with "this.getAction()".
* Note that it is up to the app to decide what to do or ignore the action ID.
*/
public boolean performAction(int actionId) {
return connection != null && actionId != EditorInfo.IME_ACTION_NONE && connection.performEditorAction(actionId);
}
}

View file

@ -0,0 +1,145 @@
package io.github.sspanak.tt9.ime.modes;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.helpers.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
abstract public class InputMode {
// typing mode
public static final int MODE_PREDICTIVE = 0;
public static final int MODE_ABC = 1;
public static final int MODE_123 = 2;
public static final int MODE_PASSTHROUGH = 4;
// text case
public static final int CASE_UNDEFINED = -1;
public static final int CASE_UPPER = 0;
public static final int CASE_CAPITALIZE = 1;
public static final int CASE_LOWER = 2;
public static final int CASE_DICTIONARY = 3; // do not force it, but use the dictionary word as-is
protected final ArrayList<Integer> allowedTextCases = new ArrayList<>();
protected int textCase = CASE_LOWER;
protected int textFieldTextCase = CASE_UNDEFINED;
// data
protected int autoAcceptTimeout = -1;
protected Language language;
protected final ArrayList<String> suggestions = new ArrayList<>();
public static InputMode getInstance(SettingsStore settings, Language language, InputType inputType, int mode) {
switch (mode) {
case MODE_PREDICTIVE:
return new ModePredictive(settings, language);
case MODE_ABC:
return new ModeABC(settings, language);
case MODE_PASSTHROUGH:
return new ModePassthrough();
default:
Logger.w("InputMode", "Defaulting to mode: " + Mode123.class.getName() + " for unknown InputMode: " + mode);
case MODE_123:
return new Mode123(inputType);
}
}
// Key handlers. Return "true" when handling the key or "false", when is nothing to do.
public boolean onBackspace() { return false; }
abstract public boolean onNumber(int number, boolean hold, int repeat);
// Suggestions
public void onAcceptSuggestion(@NonNull String word) { onAcceptSuggestion(word, false); }
public void onAcceptSuggestion(@NonNull String word, boolean preserveWordList) {}
/**
* loadSuggestions
* Loads the suggestions based on the current state, with optional "currentWord" filter.
* Once loading is finished the respective InputMode child will call "onLoad", notifying it
* the suggestions are available using "getSuggestions()".
*/
public void loadSuggestions(Runnable onLoad, String currentWord) {
onLoad.run();
}
public ArrayList<String> getSuggestions() {
ArrayList<String> newSuggestions = new ArrayList<>();
for (String s : suggestions) {
newSuggestions.add(adjustSuggestionTextCase(s, textCase));
}
return newSuggestions;
}
// Mode identifiers
public boolean isABC() { return false; }
public boolean is123() { return false; }
public boolean isPassthrough() { return false; }
public boolean isNumeric() { return false; }
// Utility
abstract public int getId();
abstract public int getSequenceLength(); // The number of key presses for the current word.
public int getAutoAcceptTimeout() {
return autoAcceptTimeout;
}
public void changeLanguage(Language newLanguage) {
if (newLanguage != null) {
language = newLanguage;
}
}
// Interaction with the IME. Return "true" if it should perform the respective action.
public boolean shouldAcceptPreviousSuggestion() { return false; }
public boolean shouldAcceptPreviousSuggestion(int nextKey) { return false; }
public boolean shouldAddAutoSpace(InputType inputType, TextField textField, boolean isWordAcceptedManually, int nextKey) { return false; }
public boolean shouldDeletePrecedingSpace(InputType inputType) { return false; }
public boolean shouldIgnoreText(String text) { return text == null || text.isEmpty(); }
public boolean shouldSelectNextSuggestion() { return false; }
public void reset() {
autoAcceptTimeout = -1;
suggestions.clear();
}
// Text case
public int getTextCase() { return textCase; }
public boolean setTextCase(int newTextCase) {
if (!allowedTextCases.contains(newTextCase)) {
return false;
}
textCase = newTextCase;
return true;
}
public void setTextFieldCase(int newTextCase) {
textFieldTextCase = allowedTextCases.contains(newTextCase) ? newTextCase : CASE_UNDEFINED;
}
public void defaultTextCase() {
textCase = allowedTextCases.get(0);
}
public void nextTextCase() {
int nextIndex = (allowedTextCases.indexOf(textCase) + 1) % allowedTextCases.size();
textCase = allowedTextCases.get(nextIndex);
}
public void determineNextWordTextCase(String textBeforeCursor) {}
// Based on the internal logic of the mode (punctuation or grammar rules), re-adjust the text case for when getSuggestions() is called.
protected String adjustSuggestionTextCase(String word, int newTextCase) { return word; }
// Stem filtering.
// Where applicable, return "true" if the mode supports it and the operation was possible.
public boolean clearWordStem() { return setWordStem("", true); }
public boolean isStemFilterFuzzy() { return false; }
public String getWordStem() { return ""; }
public boolean setWordStem(String stem, boolean exact) { return false; }
}

View file

@ -0,0 +1,114 @@
package io.github.sspanak.tt9.ime.modes;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import io.github.sspanak.tt9.ime.helpers.InputType;
import io.github.sspanak.tt9.languages.Characters;
public class Mode123 extends ModePassthrough {
@Override public int getId() { return MODE_123; }
@Override @NonNull public String toString() { return "123"; }
@Override public final boolean is123() { return true; }
@Override public boolean isPassthrough() { return false; }
@Override public int getSequenceLength() { return 1; }
@Override public boolean shouldAcceptPreviousSuggestion(int nextKey) { return true; }
private final ArrayList<ArrayList<String>> KEY_CHARACTERS = new ArrayList<>();
public Mode123(InputType inputType) {
if (inputType.isPhoneNumber()) {
getPhoneSpecialCharacters();
} else if (inputType.isNumeric()) {
getNumberSpecialCharacters(inputType.isDecimal(), inputType.isSignedNumber());
} else {
getDefaultSpecialCharacters();
}
}
/**
* getPhoneSpecialCharacters
* Special characters for phone number fields, including both characters for conveniently typing a phone number: "()-",
* as well as command characters such as "," = "slight pause" and ";" = "wait" used in Japan and some other countries.
*/
private void getPhoneSpecialCharacters() {
KEY_CHARACTERS.add(new ArrayList<>(Arrays.asList("+", " ")));
KEY_CHARACTERS.add(new ArrayList<>(Arrays.asList("-", "(", ")", ".", ";", ",")));
}
/**
* getNumberSpecialCharacters
* Special characters for all kinds of numeric fields: integer, decimal with +/- included as necessary.
*/
private void getNumberSpecialCharacters(boolean decimal, boolean signed) {
KEY_CHARACTERS.add(signed ? new ArrayList<>(Arrays.asList("-", "+")) : new ArrayList<>());
if (decimal) {
KEY_CHARACTERS.add(new ArrayList<>(Arrays.asList(".", ",")));
}
}
/**
* getDefaultSpecialCharacters
* Special characters for when the user has selected 123 mode in a text field. In this case, we just
* use the default list, but reorder it a bit for convenience.
*/
private void getDefaultSpecialCharacters() {
// 0-key
KEY_CHARACTERS.add(new ArrayList<>(Collections.singletonList("+")));
for (String character : Characters.Special) {
if (!character.equals("+") && !character.equals("\n")) {
KEY_CHARACTERS.get(0).add(character);
}
}
// 1-key
KEY_CHARACTERS.add(new ArrayList<>(Collections.singletonList(".")));
for (String character : Characters.PunctuationEnglish) {
if (!character.equals(".")) {
KEY_CHARACTERS.get(1).add(character);
}
}
}
@Override public boolean onNumber(int number, boolean hold, int repeat) {
reset();
if (hold && number < KEY_CHARACTERS.size() && KEY_CHARACTERS.get(number).size() > 0) {
suggestions.addAll(KEY_CHARACTERS.get(number));
} else {
autoAcceptTimeout = 0;
suggestions.add(String.valueOf(number));
}
return true;
}
/**
* shouldIgnoreText
* Since this is a numeric mode, we allow typing only numbers and:
* 1. In numeric fields, we must allow math chars
* 2. In dialer fields, we must allow various punctuation chars, because they are used as dialing shortcuts
* at least in Japan.
* More info and discussion: <a href="https://github.com/sspanak/tt9/issues/241">issue 241 on Github</a>.
*/
@Override public boolean shouldIgnoreText(String text) {
return
text == null
|| text.length() != 1
|| !(
(text.charAt(0) > 31 && text.charAt(0) < 65)
|| (text.charAt(0) > 90 && text.charAt(0) < 97)
|| (text.charAt(0) > 122 && text.charAt(0) < 127)
);
}
}

View file

@ -0,0 +1,86 @@
package io.github.sspanak.tt9.ime.modes;
import androidx.annotation.NonNull;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class ModeABC extends InputMode {
private final SettingsStore settings;
public int getId() { return MODE_ABC; }
private boolean shouldSelectNextLetter = false;
ModeABC(SettingsStore settings, Language lang) {
this.settings = settings;
changeLanguage(lang);
}
@Override
public boolean onNumber(int number, boolean hold, int repeat) {
if (hold) {
reset();
suggestions.add(language.getKeyNumber(number));
autoAcceptTimeout = 0;
} else if (repeat > 0) {
shouldSelectNextLetter = true;
autoAcceptTimeout = settings.getAbcAutoAcceptTimeout();
} else {
reset();
suggestions.addAll(language.getKeyCharacters(number));
autoAcceptTimeout = settings.getAbcAutoAcceptTimeout();
}
return true;
}
@Override
protected String adjustSuggestionTextCase(String word, int newTextCase) {
return newTextCase == CASE_UPPER ? word.toUpperCase(language.getLocale()) : word.toLowerCase(language.getLocale());
}
@Override
public void changeLanguage(Language language) {
super.changeLanguage(language);
allowedTextCases.clear();
allowedTextCases.add(CASE_LOWER);
if (language.hasUpperCase()) {
allowedTextCases.add(CASE_UPPER);
}
}
@Override final public boolean isABC() { return true; }
@Override public int getSequenceLength() { return 1; }
@Override public boolean shouldAcceptPreviousSuggestion() { return autoAcceptTimeout == 0 || !shouldSelectNextLetter; }
@Override public boolean shouldSelectNextSuggestion() { return shouldSelectNextLetter; }
@Override
public void reset() {
super.reset();
shouldSelectNextLetter = false;
}
@NonNull
@Override
public String toString() {
if (language == null) {
return textCase == CASE_LOWER ? "abc" : "ABC";
}
String langCode = "";
if (language.isLatinBased() || language.isCyrillic()) {
// There are many languages written using the same alphabet, so if the user has enabled multiple,
// make it clear which one is it, by appending the country code to "ABC" or "АБВ".
langCode = language.getLocale().getCountry();
langCode = langCode.isEmpty() ? language.getLocale().getLanguage() : langCode;
langCode = langCode.isEmpty() ? language.getName() : langCode;
langCode = " / " + langCode;
}
String modeString = language.getAbcString() + langCode.toUpperCase();
return (textCase == CASE_LOWER) ? modeString.toLowerCase(language.getLocale()) : modeString.toUpperCase(language.getLocale());
}
}

View file

@ -0,0 +1,21 @@
package io.github.sspanak.tt9.ime.modes;
import androidx.annotation.NonNull;
// see: InputType.isSpecialNumeric()
public class ModePassthrough extends InputMode {
ModePassthrough() {
reset();
allowedTextCases.add(CASE_LOWER);
}
@Override public int getId() { return MODE_PASSTHROUGH; }
@Override public int getSequenceLength() { return 0; }
@Override @NonNull public String toString() { return "Passthrough"; }
@Override public boolean isNumeric() { return true; }
@Override public boolean isPassthrough() { return true; }
@Override public boolean onNumber(int number, boolean hold, int repeat) { return false; }
@Override public boolean shouldIgnoreText(String text) { return true; }
}

View file

@ -0,0 +1,391 @@
package io.github.sspanak.tt9.ime.modes;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.TextTools;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.ime.helpers.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.ime.modes.helpers.AutoSpace;
import io.github.sspanak.tt9.ime.modes.helpers.AutoTextCase;
import io.github.sspanak.tt9.ime.modes.helpers.Predictions;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class ModePredictive extends InputMode {
private final String LOG_TAG = getClass().getSimpleName();
private final SettingsStore settings;
public int getId() { return MODE_PREDICTIVE; }
private String digitSequence = "";
private String lastAcceptedWord = "";
// stem filter
private boolean isStemFuzzy = false;
private String stem = "";
// async suggestion handling
private boolean disablePredictions = false;
private Runnable onSuggestionsUpdated;
// text analysis tools
private final AutoSpace autoSpace;
private final AutoTextCase autoTextCase;
private final Predictions predictions;
private boolean isCursorDirectionForward = false;
ModePredictive(SettingsStore settings, Language lang) {
changeLanguage(lang);
defaultTextCase();
autoSpace = new AutoSpace(settings);
autoTextCase = new AutoTextCase(settings);
predictions = new Predictions(settings);
this.settings = settings;
}
@Override
public boolean onBackspace() {
isCursorDirectionForward = false;
if (digitSequence.length() < 1) {
clearWordStem();
return false;
}
digitSequence = digitSequence.substring(0, digitSequence.length() - 1);
if (digitSequence.length() == 0) {
clearWordStem();
} else if (stem.length() > digitSequence.length()) {
stem = stem.substring(0, digitSequence.length());
}
return true;
}
@Override
public boolean onNumber(int number, boolean hold, int repeat) {
isCursorDirectionForward = true;
if (hold) {
// hold to type any digit
reset();
autoAcceptTimeout = 0;
disablePredictions = true;
suggestions.add(language.getKeyNumber(number));
} else {
// words
super.reset();
disablePredictions = false;
digitSequence += number;
if (number == 0 && repeat > 0) {
autoAcceptTimeout = 0;
}
}
return true;
}
@Override
public void changeLanguage(Language language) {
super.changeLanguage(language);
allowedTextCases.clear();
allowedTextCases.add(CASE_LOWER);
if (language.hasUpperCase()) {
allowedTextCases.add(CASE_CAPITALIZE);
allowedTextCases.add(CASE_UPPER);
}
}
@Override
public void reset() {
super.reset();
digitSequence = "";
disablePredictions = false;
stem = "";
}
/**
* clearLastAcceptedWord
* Removes the last accepted word from the suggestions list and the "digitSequence"
* or stops silently, when there is nothing to do.
*/
private void clearLastAcceptedWord() {
if (
lastAcceptedWord.isEmpty()
|| suggestions.isEmpty()
|| !suggestions.get(0).toLowerCase(language.getLocale()).startsWith(lastAcceptedWord.toLowerCase(language.getLocale()))
) {
return;
}
int lastAcceptedWordLength = lastAcceptedWord.length();
digitSequence = digitSequence.length() > lastAcceptedWordLength ? digitSequence.substring(lastAcceptedWordLength) : "";
ArrayList<String> lastSuggestions = new ArrayList<>(suggestions);
suggestions.clear();
for (String s : lastSuggestions) {
suggestions.add(s.length() >= lastAcceptedWordLength ? s.substring(lastAcceptedWordLength) : "");
}
}
/**
* setWordStem
* Filter the possible suggestions by the given stem.
*
* If exact is "true", the database will be filtered by "stem" and if the stem word is missing,
* it will be added to the suggestions list.
* For example: "exac_" -> "exac", {database suggestions...}
*
* If "exact" is false, in addition to the above, all possible next combinations will be
* added to the suggestions list, even if they make no sense.
* For example: "exac_" -> "exac", "exact", "exacu", "exacv", {database suggestions...}
*
* Note that you need to manually get the suggestions again to obtain a filtered list.
*/
@Override
public boolean setWordStem(String newStem, boolean exact) {
if (newStem == null || newStem.isEmpty()) {
isStemFuzzy = false;
stem = "";
Logger.d(LOG_TAG, "Stem filter cleared");
return true;
}
try {
digitSequence = language.getDigitSequenceForWord(newStem);
isStemFuzzy = !exact;
stem = newStem.toLowerCase(language.getLocale());
Logger.d(LOG_TAG, "Stem is now: " + stem + (isStemFuzzy ? " (fuzzy)" : ""));
return true;
} catch (Exception e) {
isStemFuzzy = false;
stem = "";
Logger.w("setWordStem", "Ignoring invalid stem: " + newStem + " in language: " + language + ". " + e.getMessage());
return false;
}
}
/**
* getWordStem
* If "setWordStem()" has accepted a new stem by returning "true", it can be obtained using this.
*/
@Override
public String getWordStem() {
return stem;
}
/**
* isStemFilterFuzzy
* Returns how strict the stem filter is.
*/
@Override
public boolean isStemFilterFuzzy() {
return isStemFuzzy;
}
/**
* loadSuggestions
* Loads the possible list of suggestions for the current digitSequence.
* Returns "false" on invalid sequence.
*
* "currentWord" is used for generating suggestions when there are no results.
* See: Predictions.generatePossibleCompletions()
*/
@Override
public void loadSuggestions(Runnable onLoad, String currentWord) {
if (disablePredictions) {
super.loadSuggestions(onLoad, currentWord);
return;
}
onSuggestionsUpdated = onLoad;
predictions
.setDigitSequence(digitSequence)
.setIsStemFuzzy(isStemFuzzy)
.setStem(stem)
.setLanguage(language)
.setInputWord(currentWord)
.setWordsChangedHandler(this::getPredictions)
.load();
}
/**
* getPredictions
* Gets the currently available Predictions and sends them over to the external caller.
*/
private void getPredictions() {
digitSequence = predictions.getDigitSequence();
suggestions.clear();
suggestions.addAll(predictions.getList());
onSuggestionsUpdated.run();
}
/**
* onAcceptSuggestion
* Bring this word up in the suggestions list next time and if necessary preserves the suggestion list
* with "currentWord" cleaned from them.
*/
@Override
public void onAcceptSuggestion(@NonNull String currentWord, boolean preserveWords) {
lastAcceptedWord = currentWord;
if (preserveWords) {
clearLastAcceptedWord();
} else {
reset();
}
stem = "";
if (currentWord.isEmpty()) {
Logger.i(LOG_TAG, "Current word is empty. Nothing to accept.");
return;
}
// increment the frequency of the given word
try {
String sequence = language.getDigitSequenceForWord(currentWord);
// emoji and punctuation are not in the database, so there is no point in
// running queries that would update nothing
if (!sequence.startsWith("11") && !sequence.equals("1") && !sequence.startsWith("0")) {
WordStoreAsync.makeTopWord(language, currentWord, sequence);
}
} catch (Exception e) {
Logger.e(LOG_TAG, "Failed incrementing priority of word: '" + currentWord + "'. " + e.getMessage());
}
}
@Override
protected String adjustSuggestionTextCase(String word, int newTextCase) {
return autoTextCase.adjustSuggestionTextCase(language, word, newTextCase);
}
@Override
public void determineNextWordTextCase(String textBeforeCursor) {
textCase = autoTextCase.determineNextWordTextCase(textCase, textFieldTextCase, textBeforeCursor);
}
@Override
public int getTextCase() {
// Filter out the internally used text cases. They have no meaning outside this class.
return (textCase == CASE_UPPER || textCase == CASE_LOWER) ? textCase : CASE_CAPITALIZE;
}
@Override
public void nextTextCase() {
textFieldTextCase = CASE_UNDEFINED; // since it's a user's choice, the default matters no more
super.nextTextCase();
}
/**
* shouldAcceptPreviousSuggestion
* Automatic space assistance. Spaces (and special chars) cause suggestions to be accepted
* automatically. This is used for analysis before processing the incoming pressed key.
*/
@Override
public boolean shouldAcceptPreviousSuggestion(int nextKey) {
return
!digitSequence.isEmpty() && (
(nextKey == 0 && digitSequence.charAt(digitSequence.length() - 1) != '0')
|| (nextKey != 0 && digitSequence.charAt(digitSequence.length() - 1) == '0')
);
}
/**
* shouldAcceptPreviousSuggestion
* Variant for post suggestion load analysis.
*/
@Override
public boolean shouldAcceptPreviousSuggestion() {
// backspace never breaks words
if (!isCursorDirectionForward) {
return false;
}
// special characters always break words
if (autoAcceptTimeout == 0 && !digitSequence.startsWith("0")) {
return true;
}
// allow apostrophes in the middle or at the end of Hebrew and Ukrainian words
if (language.isHebrew() || language.isUkrainian()) {
return
predictions.noDbWords()
&& digitSequence.equals("1");
}
// punctuation breaks words, unless there are database matches ('s, qu', по-, etc...)
return
!digitSequence.isEmpty()
&& predictions.noDbWords()
&& digitSequence.contains("1")
&& TextTools.containsOtherThan1(digitSequence);
}
@Override
public boolean shouldAddAutoSpace(InputType inputType, TextField textField, boolean isWordAcceptedManually, int nextKey) {
return autoSpace
.setLastWord(lastAcceptedWord)
.setLastSequence()
.setInputType(inputType)
.setTextField(textField)
.shouldAddAutoSpace(isWordAcceptedManually, nextKey);
}
@Override
public boolean shouldDeletePrecedingSpace(InputType inputType) {
return autoSpace
.setLastWord(lastAcceptedWord)
.setInputType(inputType)
.setTextField(null)
.shouldDeletePrecedingSpace();
}
@Override public int getSequenceLength() { return digitSequence.length(); }
@NonNull
@Override
public String toString() {
if (language == null) {
return "Predictive";
}
String modeString = language.getName();
if (textCase == CASE_UPPER) {
return modeString.toUpperCase(language.getLocale());
} else if (textCase == CASE_LOWER && !settings.getAutoTextCase()) {
return modeString.toLowerCase(language.getLocale());
} else {
return modeString;
}
}
}

View file

@ -0,0 +1,130 @@
package io.github.sspanak.tt9.ime.modes.helpers;
import io.github.sspanak.tt9.TextTools;
import io.github.sspanak.tt9.ime.helpers.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class AutoSpace {
private final SettingsStore settings;
private InputType inputType;
private TextField textField;
private String lastWord;
public AutoSpace(SettingsStore settingsStore) {
settings = settingsStore;
}
public AutoSpace setInputType(InputType inputType) {
this.inputType = inputType;
return this;
}
public AutoSpace setTextField(TextField textField) {
this.textField = textField;
return this;
}
public AutoSpace setLastWord(String lastWord) {
this.lastWord = lastWord;
return this;
}
public AutoSpace setLastSequence() {
return this;
}
/**
* shouldAddAutoSpace
* When the "auto-space" settings is enabled, this determines whether to automatically add a space
* at the end of a sentence or after accepting a suggestion. This allows faster typing, without
* pressing space.
*
* See the helper functions for the list of rules.
*/
public boolean shouldAddAutoSpace(boolean isWordAcceptedManually, int nextKey) {
String previousChars = textField.getPreviousChars(2);
String nextChars = textField.getNextChars(2);
return
settings.getAutoSpace()
&& !inputType.isSpecialized()
&& nextKey != 0
&& !TextTools.startsWithWhitespace(nextChars)
&& (
shouldAddAfterWord(isWordAcceptedManually, previousChars, nextChars, nextKey)
|| shouldAddAfterPunctuation(previousChars, nextChars, nextKey)
);
}
/**
* shouldAddAfterPunctuation
* Determines whether to automatically adding a space after certain punctuation signs makes sense.
* The rules are similar to the ones in the standard Android keyboard (with some exceptions,
* because we are not using a QWERTY keyboard here).
*/
private boolean shouldAddAfterPunctuation(String previousChars, String nextChars, int nextKey) {
char previousChar = previousChars.isEmpty() ? 0 : previousChars.charAt(previousChars.length() - 1);
return
nextKey != 1
&& !TextTools.nextIsPunctuation(nextChars)
&& !TextTools.startsWithNumber(nextChars)
&& (
previousChar == '.'
|| previousChar == ','
|| previousChar == ';'
|| previousChar == ':'
|| previousChar == '!'
|| previousChar == '?'
|| previousChar == ')'
|| previousChar == ']'
|| previousChar == '%'
|| previousChar == '»'
|| previousChar == '؟'
|| previousChar == '“'
|| previousChars.endsWith(" -")
|| previousChars.endsWith(" /")
);
}
/**
* shouldAddAfterWord
* Similar to "shouldAddAfterPunctuation()", but determines whether to add a space after words.
*/
private boolean shouldAddAfterWord(boolean isWordAcceptedManually, String previousChars, String nextChars, int nextKey) {
return
isWordAcceptedManually // Do not add space when auto-accepting words, because it feels very confusing when typing.
&& nextKey != 1
&& nextChars.isEmpty()
&& TextTools.previousIsLetter(previousChars);
}
/**
* shouldDeletePrecedingSpace
* When the "auto-space" settings is enabled, determine whether to delete spaces before punctuation.
* This allows automatic conversion from: "words ." to: "words."
*/
public boolean shouldDeletePrecedingSpace() {
return
settings.getAutoSpace()
&& (
lastWord.equals(".")
|| lastWord.equals(",")
|| lastWord.equals(";")
|| lastWord.equals(":")
|| lastWord.equals("!")
|| lastWord.equals("?")
|| lastWord.equals("؟")
|| lastWord.equals(")")
|| lastWord.equals("]")
|| lastWord.equals("'")
|| lastWord.equals("@")
)
&& !inputType.isSpecialized();
}
}

View file

@ -0,0 +1,77 @@
package io.github.sspanak.tt9.ime.modes.helpers;
import io.github.sspanak.tt9.TextTools;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class AutoTextCase {
private final SettingsStore settings;
public AutoTextCase(SettingsStore settingsStore) {
settings = settingsStore;
}
/**
* adjustSuggestionTextCase
* In addition to uppercase/lowercase, here we use the result from determineNextWordTextCase(),
* to conveniently start sentences with capitals or whatnot.
*
* Also, by default we preserve any mixed case words in the dictionary,
* for example: "dB", "Mb", proper names, German nouns, that always start with a capital,
* or Dutch words such as: "'s-Hertogenbosch".
*/
public String adjustSuggestionTextCase(Language language, String word, int newTextCase) {
switch (newTextCase) {
case InputMode.CASE_UPPER:
return word.toUpperCase(language.getLocale());
case InputMode.CASE_LOWER:
return word.toLowerCase(language.getLocale());
case InputMode.CASE_CAPITALIZE:
return language.isMixedCaseWord(word) || language.isUpperCaseWord(word) ? word : language.capitalize(word);
default:
return word;
}
}
/**
* determineNextWordTextCase
* Dynamically determine text case of words as the user types, to reduce key presses.
* For example, this function will return CASE_LOWER by default, but CASE_UPPER at the beginning
* of a sentence.
*/
public int determineNextWordTextCase(int currentTextCase, int textFieldTextCase, String textBeforeCursor) {
if (
// When the setting is off, don't do any changes.
!settings.getAutoTextCase()
// If the user wants to type in uppercase, this must be for a reason, so we better not override it.
|| currentTextCase == InputMode.CASE_UPPER
) {
return currentTextCase;
}
if (textFieldTextCase != InputMode.CASE_UNDEFINED) {
return textFieldTextCase;
}
// start of text
if (textBeforeCursor.isEmpty()) {
return InputMode.CASE_CAPITALIZE;
}
// start of sentence, excluding after "..."
if (TextTools.isStartOfSentence(textBeforeCursor)) {
return InputMode.CASE_CAPITALIZE;
}
// this is mostly for English "I"
if (TextTools.isNextToWord(textBeforeCursor)) {
return InputMode.CASE_LOWER;
}
return InputMode.CASE_DICTIONARY;
}
}

View file

@ -0,0 +1,334 @@
package io.github.sspanak.tt9.ime.modes.helpers;
import java.util.ArrayList;
import java.util.regex.Pattern;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.ime.EmptyDatabaseWarning;
import io.github.sspanak.tt9.languages.Characters;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class Predictions {
private final EmptyDatabaseWarning emptyDbWarning;
private final SettingsStore settings;
private Language language;
private String digitSequence;
private boolean isStemFuzzy;
private String stem;
private String inputWord;
// async operations
private Runnable onWordsChanged = () -> {};
// data
private boolean areThereDbWords = false;
private ArrayList<String> words = new ArrayList<>();
// punctuation/emoji
private final Pattern containsOnly1Regex = Pattern.compile("^1+$");
private final String maxEmojiSequence;
public Predictions(SettingsStore settingsStore) {
emptyDbWarning = new EmptyDatabaseWarning();
settings = settingsStore;
// digitSequence limiter when selecting emoji
// "11" = Emoji level 0, "111" = Emoji level 1,... up to the maximum amount of 1s
StringBuilder maxEmojiSequenceBuilder = new StringBuilder();
for (int i = 0; i <= Characters.getEmojiLevels(); i++) {
maxEmojiSequenceBuilder.append("1");
}
maxEmojiSequence = maxEmojiSequenceBuilder.toString();
}
public Predictions setLanguage(Language language) {
this.language = language;
return this;
}
public Predictions setDigitSequence(String digitSequence) {
this.digitSequence = digitSequence;
return this;
}
public String getDigitSequence() {
return digitSequence;
}
public Predictions setIsStemFuzzy(boolean yes) {
this.isStemFuzzy = yes;
return this;
}
public Predictions setStem(String stem) {
this.stem = stem;
return this;
}
public Predictions setInputWord(String inputWord) {
this.inputWord = inputWord.toLowerCase(language.getLocale());
return this;
}
public Predictions setWordsChangedHandler(Runnable handler) {
onWordsChanged = handler;
return this;
}
public ArrayList<String> getList() {
return words;
}
public boolean noDbWords() {
return !areThereDbWords;
}
/**
* suggestStem
* Add the current stem filter to the predictions list, when it has length of X and
* the user has pressed X keys (otherwise, it makes no sense to add it).
*/
private void suggestStem() {
if (!stem.isEmpty() && stem.length() == digitSequence.length()) {
words.add(stem);
}
}
/**
* suggestMissingWords
* Takes a list of words and appends them to the words list, if they are missing.
*/
private void suggestMissingWords(ArrayList<String> newWords) {
for (String newWord : newWords) {
if (!words.contains(newWord) && !words.contains(newWord.toLowerCase(language.getLocale()))) {
words.add(newWord);
}
}
}
/**
* load
* Queries the dictionary database for a list of words matching the current language and
* sequence or loads the static ones.
*/
public void load() {
if (digitSequence == null || digitSequence.isEmpty()) {
words.clear();
onWordsChanged.run();
return;
}
if (loadStatic()) {
onWordsChanged.run();
} else {
WordStoreAsync.getWords(
(words) -> onDbWords(words, true),
language,
digitSequence,
stem,
SettingsStore.SUGGESTIONS_MIN,
SettingsStore.SUGGESTIONS_MAX
);
}
}
/**
* loadStatic
* Similar to "load()", but loads words that are not in the database.
* Returns "false", when there are no static options for the current digitSequence.
*/
private boolean loadStatic() {
// whitespace/special/math characters
if (digitSequence.equals("0")) {
stem = "";
words.clear();
words.addAll(language.getKeyCharacters(0, false));
}
// "00" is a shortcut for the preferred character
else if (digitSequence.equals("00")) {
stem = "";
words.clear();
words.add(settings.getDoubleZeroChar());
}
// emoji
else if (containsOnly1Regex.matcher(digitSequence).matches()) {
stem = "";
words.clear();
if (digitSequence.length() == 1) {
words.addAll(language.getKeyCharacters(1, false));
} else {
digitSequence = digitSequence.length() <= maxEmojiSequence.length() ? digitSequence : maxEmojiSequence;
words.addAll(Characters.getEmoji(digitSequence.length() - 2));
}
} else {
return false;
}
return true;
}
private void loadWithoutLeadingPunctuation() {
WordStoreAsync.getWords(
(dbWords) -> {
char firstChar = inputWord.charAt(0);
for (int i = 0; i < dbWords.size(); i++) {
dbWords.set(i, firstChar + dbWords.get(i));
}
onDbWords(dbWords, false);
},
language,
digitSequence.substring(1),
stem.length() > 1 ? stem.substring(1) : "",
SettingsStore.SUGGESTIONS_MIN,
SettingsStore.SUGGESTIONS_MAX
);
}
/**
* dbWordsHandler
* 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 void onDbWords(ArrayList<String> dbWords, boolean isRetryAllowed) {
// only the first round matters, the second one is just for getting the letters for a given key
areThereDbWords = !dbWords.isEmpty() && isRetryAllowed;
// If there were no database words for ",a", try getting the letters only (e.g. "a", "b", "c").
// We do this to display them in the correct order.
if (dbWords.isEmpty() && isRetryAllowed && digitSequence.length() == 2 && digitSequence.charAt(0) == '1') {
loadWithoutLeadingPunctuation();
return;
}
if (dbWords.isEmpty() && !digitSequence.isEmpty()) {
emptyDbWarning.emitOnce(language);
}
words.clear();
suggestStem();
suggestMissingWords(generatePossibleStemVariations(dbWords));
suggestMissingWords(dbWords.isEmpty() ? generateWordVariations(inputWord) : dbWords);
words = insertPunctuationCompletions(words);
onWordsChanged.run();
}
/**
* generateWordVariations
* When there are no matching suggestions after the last key press, generate a list of possible
* ones, so that the user can complete a missing word that is completely different from the ones
* in the dictionary.
*
* For example, if the word is "missin_" and the last pressed key is "4", the results would be:
* | missing | missinh | missini |
*/
private ArrayList<String> generateWordVariations(String baseWord) {
ArrayList<String> generatedWords = new ArrayList<>();
// Make sure the displayed word and the digit sequence, we will be generating suggestions from,
// have the same length, to prevent visual discrepancies.
baseWord = (baseWord != null && !baseWord.isEmpty()) ? baseWord.substring(0, Math.min(digitSequence.length() - 1, baseWord.length())) : "";
// append all letters for the last digit in the sequence (the last pressed key)
int lastSequenceDigit = digitSequence.charAt(digitSequence.length() - 1) - '0';
for (String keyLetter : language.getKeyCharacters(lastSequenceDigit, false)) {
generatedWords.add(baseWord + keyLetter);
}
// if there are no letters for this key, just append the number
if (generatedWords.isEmpty()) {
generatedWords.add(baseWord + digitSequence.charAt(digitSequence.length() - 1));
}
return generatedWords;
}
/**
* insertPunctuationCompletions
* When given: "you'", for example, this inserts all other 1-key alternatives, like:
* "you.", "you?", "you!" and so on. The generated words will be inserted after the direct
* database matches and before the fuzzy matches, as if they were direct matches with low frequency.
* This is to preserve the sorting by length and frequency.
*/
private ArrayList<String> insertPunctuationCompletions(ArrayList<String> dbWords) {
if (!stem.isEmpty() || dbWords.isEmpty() || digitSequence.length() < 2 || !digitSequence.endsWith("1")) {
return dbWords;
}
ArrayList<String> complementedWords = new ArrayList<>();
int exactMatchLength = digitSequence.length();
// shortest database words (exact matches)
for (String w : dbWords) {
if (w.length() <= exactMatchLength) {
complementedWords.add(w);
}
}
// generated "exact matches"
for (String w : generateWordVariations(dbWords.get(0))) {
if (!dbWords.contains(w) && !dbWords.contains(w.toLowerCase(language.getLocale()))) {
complementedWords.add(w);
}
}
// longer database words (fuzzy matches)
for (String w : dbWords) {
if (w.length() > exactMatchLength) {
complementedWords.add(w);
}
}
return complementedWords;
}
/**
* generatePossibleStemVariations
* Similar to generatePossibleCompletions(), but uses the current filter as a base word. This is
* used to complement the database results with all possible variations for the next key, when
* the stem filter is on.
*
* It will not generate anything if more than one key was pressed after filtering though.
*
* For example, if the filter is "extr", the current word is "extr_" and the user has pressed "1",
* the database would have returned only "extra", but this function would also
* generate: "extrb" and "extrc". This is useful for typing an unknown word, that is similar to
* the ones in the dictionary.
*/
private ArrayList<String> generatePossibleStemVariations(ArrayList<String> dbWords) {
ArrayList<String> variations = new ArrayList<>();
if (isStemFuzzy && !stem.isEmpty() && stem.length() == digitSequence.length() - 1) {
ArrayList<String> allPossibleVariations = generateWordVariations(stem);
// first add the known words, because it makes more sense to see them first
for (String variation : allPossibleVariations) {
if (dbWords.contains(variation)) {
variations.add(variation);
}
}
// then add the unknown ones, so they can be used as possible beginnings of new words.
for (String word : allPossibleVariations) {
if (!dbWords.contains(word)) {
variations.add(word);
}
}
}
return variations;
}
}

View file

@ -0,0 +1,84 @@
package io.github.sspanak.tt9.languages;
import android.graphics.Paint;
import android.os.Build;
import java.util.ArrayList;
import java.util.Arrays;
public class Characters {
final public static ArrayList<String> ArabicNumbers = new ArrayList<>(Arrays.asList(
"٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"
));
final public static ArrayList<String> PunctuationArabic = new ArrayList<>(Arrays.asList(
"،", ".", "-", "(", ")", "[", "]", "&", "~", "`", "\"", "'", "؛", ":", "!", "؟"
));
final public static ArrayList<String> PunctuationEnglish = new ArrayList<>(Arrays.asList(
",", ".", "-", "(", ")", "[", "]", "&", "~", "`", "'", ";", ":", "\"", "!", "?"
));
final public static ArrayList<String> PunctuationFrench = new ArrayList<>(Arrays.asList(
",", ".", "-", "«", "»", "(", ")", "[", "]", "&", "~", "`", "\"", "'", ";", ":", "!", "?"
));
final public static ArrayList<String> PunctuationGerman = new ArrayList<>(Arrays.asList(
",", ".", "-", "", "", "(", ")", "[", "]", "&", "~", "`", "'", ";", ":", "!", "?"
));
final public static ArrayList<String> Special = new ArrayList<>(Arrays.asList(
" ", "\n", "@", "_", "#", "%", "$", "{", "}", "|", "^", "<", ">", "\\", "/", "=", "*", "+"
));
final private static ArrayList<String> TextEmoticons = new ArrayList<>(Arrays.asList(
":)", ":D", ":P", ";)", "\\m/", ":-O", ":|", ":("
));
final private static ArrayList<ArrayList<String>> Emoji = new ArrayList<>(Arrays.asList(
// positive
new ArrayList<>(Arrays.asList(
"🙂", "😀", "🤣", "🤓", "😎", "😛", "😉"
)),
// negative
new ArrayList<>(Arrays.asList(
"🙁", "😢", "😭", "😱", "😲", "😳", "😐", "😠"
)),
// hands
new ArrayList<>(Arrays.asList(
"👍", "👋", "✌️", "👏", "🖖", "🤘", "🤝", "💪", "👎"
)),
// emotions
new ArrayList<>(Arrays.asList(
"", "🤗", "😍", "😘", "😇", "😈", "🍺", "🎉", "🥱", "🤔", "🥶", "😬"
))
));
public static boolean noEmojiSupported() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M;
}
public static int getEmojiLevels() {
return noEmojiSupported() ? 1 : Emoji.size();
}
public static ArrayList<String> getEmoji(int level) {
if (noEmojiSupported()) {
return new ArrayList<>(TextEmoticons);
}
level = (Emoji.size() > level) ? level : Emoji.size() - 1;
Paint paint = new Paint();
ArrayList<String> availableEmoji = new ArrayList<>();
for (String emoji : Emoji.get(level)) {
if (paint.hasGlyph(emoji)) {
availableEmoji.add(emoji);
}
}
return availableEmoji.size() > 0 ? availableEmoji : new ArrayList<>(TextEmoticons);
}
}

View file

@ -0,0 +1,10 @@
package io.github.sspanak.tt9.languages;
public class InvalidLanguageCharactersException extends Exception {
public InvalidLanguageCharactersException(Language language, String extraMessage) {
super("Some characters are not supported in language: " + language.getName() + ". " + extraMessage);
}
}

View file

@ -0,0 +1,5 @@
package io.github.sspanak.tt9.languages;
public class InvalidLanguageException extends Exception {
public InvalidLanguageException() { super("Invalid Language"); }
}

View file

@ -0,0 +1,304 @@
package io.github.sspanak.tt9.languages;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;
public class Language {
private int id;
protected String name;
protected Locale locale;
protected String dictionaryFile;
protected boolean hasUpperCase = true;
protected String abcString;
protected final ArrayList<ArrayList<String>> layout = new ArrayList<>();
private final HashMap<Character, String> characterKeyMap = new HashMap<>();
public static Language fromDefinition(LanguageDefinition definition) throws Exception {
if (definition.dictionaryFile.isEmpty()) {
throw new Exception("Invalid definition. Dictionary file must be set.");
}
if (definition.locale.isEmpty()) {
throw new Exception("Invalid definition. Locale cannot be empty.");
}
Locale definitionLocale;
switch (definition.locale) {
case "de":
definitionLocale = Locale.GERMAN;
break;
case "en":
definitionLocale = Locale.ENGLISH;
break;
case "fr":
definitionLocale = Locale.FRENCH;
break;
case "it":
definitionLocale = Locale.ITALIAN;
break;
default:
String[] parts = definition.locale.split("-", 2);
if (parts.length == 2) {
definitionLocale = new Locale(parts[0], parts[1]);
} else if (parts.length == 1) {
definitionLocale = new Locale(parts[0]);
} else {
throw new Exception("Unrecognized locale format: '" + definition.locale + "'.");
}
}
Language lang = new Language();
lang.abcString = definition.abcString.isEmpty() ? null : definition.abcString;
lang.dictionaryFile = definition.getDictionaryFile();
lang.hasUpperCase = definition.hasUpperCase;
lang.locale = definitionLocale;
lang.name = definition.name.isEmpty() ? lang.name : definition.name;
for (int key = 0; key <= 9 && key < definition.layout.size(); key++) {
lang.layout.add(keyCharsFromDefinition(key, definition.layout.get(key)));
}
return lang;
}
private static ArrayList<String> keyCharsFromDefinition(int key, ArrayList<String> definitionChars) {
if (key > 1) {
return definitionChars;
}
final String specialCharsPlaceholder = "SPECIAL";
final String punctuationPlaceholder = "PUNCTUATION";
final String arabicStylePlaceholder = punctuationPlaceholder + "_AR";
final String germanStylePlaceholder = punctuationPlaceholder + "_DE";
final String frenchStylePlaceholder = punctuationPlaceholder + "_FR";
ArrayList<String> keyChars = new ArrayList<>();
for (String defChar : definitionChars) {
switch (defChar) {
case specialCharsPlaceholder:
keyChars.addAll(Characters.Special);
break;
case punctuationPlaceholder:
keyChars.addAll(Characters.PunctuationEnglish);
break;
case arabicStylePlaceholder:
keyChars.addAll(Characters.PunctuationArabic);
break;
case germanStylePlaceholder:
keyChars.addAll(Characters.PunctuationGerman);
break;
case frenchStylePlaceholder:
keyChars.addAll(Characters.PunctuationFrench);
break;
default:
keyChars.add(defChar);
break;
}
}
return keyChars;
}
final public int getId() {
if (id == 0) {
id = generateId();
}
return id;
}
final public Locale getLocale() {
return locale;
}
final public String getName() {
if (name == null) {
name = locale != null ? capitalize(locale.getDisplayLanguage(locale)) : "";
}
return name;
}
final public String getDictionaryFile() {
return dictionaryFile;
}
final public String getAbcString() {
if (abcString == null) {
ArrayList<String> lettersList = getKeyCharacters(2, false);
abcString = "";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lettersList.size() && i < 3; i++) {
sb.append(lettersList.get(i));
}
abcString = sb.toString();
}
return abcString;
}
public boolean hasUpperCase() {
return hasUpperCase;
}
/**
* isLatinBased
* Returns "true" when the language is based on the Latin alphabet or "false" otherwise.
*/
public boolean isLatinBased() {
return getKeyCharacters(2, false).contains("a");
}
public boolean isCyrillic() {
return getKeyCharacters(2, false).contains("а");
}
public boolean isRTL() {
return isArabic() || isHebrew();
}
public boolean isGreek() {
return getKeyCharacters(2, false).contains("α");
}
public boolean isArabic() {
return getKeyCharacters(3, false).contains("ا");
}
public boolean isUkrainian() {
return getKeyCharacters(3, false).contains("є");
}
public boolean isHebrew() {
return getKeyCharacters(3, false).contains("א");
}
/* ************ utility ************ */
/**
* generateId
* Uses the letters of the Locale to generate an ID for the language.
* Each letter is converted to uppercase and used as a 5-bit integer. Then the 5-bits
* are packed to form a 10-bit or a 20-bit integer, depending on the Locale length.
*
* Example (2-letter Locale)
* "en"
* -> "E" | "N"
* -> 5 | 448 (shift the 2nd number by 5 bits, so its bits would not overlap with the 1st one)
* -> 543
*
* Example (4-letter Locale)
* "bg-BG"
* -> "B" | "G" | "B" | "G"
* -> 2 | 224 | 2048 | 229376 (shift each 5-bit number, not overlap with the previous ones)
* -> 231650
*
* Maximum ID is: "zz-ZZ" -> 879450
*/
private int generateId() {
String idString = (locale.getLanguage() + locale.getCountry()).toUpperCase();
int idInt = 0;
for (int i = 0; i < idString.length(); i++) {
idInt |= ((idString.codePointAt(i) & 31) << (i * 5));
}
return idInt;
}
private void generateCharacterKeyMap() {
characterKeyMap.clear();
for (int digit = 0; digit <= 9; digit++) {
for (String keyChar : getKeyCharacters(digit)) {
characterKeyMap.put(keyChar.charAt(0), String.valueOf(digit));
}
}
}
public String capitalize(String word) {
if (word == null) {
return null;
}
String capitalizedWord = "";
if (!word.isEmpty()) {
capitalizedWord += word.substring(0, 1).toUpperCase(locale);
}
if (word.length() > 1) {
capitalizedWord += word.substring(1).toLowerCase(locale);
}
return capitalizedWord;
}
public boolean isMixedCaseWord(String word) {
return
word != null
&& !word.toLowerCase(locale).equals(word)
&& !word.toUpperCase(locale).equals(word);
}
public boolean isUpperCaseWord(String word) {
return word != null && word.toUpperCase(locale).equals(word);
}
public ArrayList<String> getKeyCharacters(int key, boolean includeDigit) {
if (key < 0 || key >= layout.size()) {
return new ArrayList<>();
}
ArrayList<String> chars = new ArrayList<>(layout.get(key));
if (includeDigit && chars.size() > 0) {
chars.add(getKeyNumber(key));
}
return chars;
}
public ArrayList<String> getKeyCharacters(int key) {
return getKeyCharacters(key, true);
}
public String getKeyNumber(int key) {
if (key > 10 || key < 0) {
return null;
} else {
return isArabic() ? Characters.ArabicNumbers.get(key) : String.valueOf(key);
}
}
public String getDigitSequenceForWord(String word) throws InvalidLanguageCharactersException {
StringBuilder sequence = new StringBuilder();
String lowerCaseWord = word.toLowerCase(locale);
if (characterKeyMap.isEmpty()) {
generateCharacterKeyMap();
}
for (int i = 0; i < lowerCaseWord.length(); i++) {
char letter = lowerCaseWord.charAt(i);
if (!characterKeyMap.containsKey(letter)) {
throw new InvalidLanguageCharactersException(this, "Failed generating digit sequence for word: '" + word);
}
sequence.append(characterKeyMap.get(letter));
}
return sequence.toString();
}
@NonNull
@Override
public String toString() {
return getName();
}
}

View file

@ -0,0 +1,110 @@
package io.github.sspanak.tt9.languages;
import android.content.Context;
import android.os.Build;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import io.github.sspanak.tt9.Logger;
public class LanguageCollection {
private static LanguageCollection self;
private final HashMap<Integer, Language> languages = new HashMap<>();
private LanguageCollection(Context context) {
for (String file : LanguageDefinition.getAllFiles(context.getAssets())) {
try {
Language lang = Language.fromDefinition(LanguageDefinition.fromFile(context.getAssets(), file));
languages.put(lang.getId(), lang);
} catch (Exception e) {
Logger.e("tt9.LanguageCollection", "Skipping invalid language: '" + file + "'. " + e.getMessage());
}
}
}
public static LanguageCollection getInstance(Context context) {
if (self == null) {
self = new LanguageCollection(context);
}
return self;
}
@Nullable
public static Language getLanguage(Context context, int langId) {
if (getInstance(context).languages.containsKey(langId)) {
return getInstance(context).languages.get(langId);
}
return null;
}
public static Language getDefault(Context context) {
Language language = getByLocale(context, "en");
return language == null ? new NullLanguage(context) : language;
}
@Nullable
public static Language getByLocale(Context context, String locale) {
for (Language lang : getInstance(context).languages.values()) {
if (lang.getLocale().toString().equals(locale)) {
return lang;
}
}
return null;
}
public static ArrayList<Language> getAll(Context context, ArrayList<Integer> languageIds, boolean sort) {
ArrayList<Language> langList = new ArrayList<>();
for (int languageId : languageIds) {
Language lang = getLanguage(context, languageId);
if (lang != null) {
langList.add(lang);
}
}
if (sort && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
langList.sort(Comparator.comparing(l -> l.getLocale().toString()));
}
return langList;
}
public static ArrayList<Language> getAll(Context context, ArrayList<Integer> languageIds) {
return getAll(context, languageIds, false);
}
public static ArrayList<Language> getAll(Context context, boolean sort) {
ArrayList<Language> langList = new ArrayList<>(getInstance(context).languages.values());
if (sort && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
langList.sort(Comparator.comparing(l -> l.getLocale().toString()));
}
return langList;
}
public static ArrayList<Language> getAll(Context context) {
return getAll(context,false);
}
public static String toString(ArrayList<Language> list) {
StringBuilder stringList = new StringBuilder();
int listSize = list.size();
for (int i = 0; i < listSize; i++) {
stringList.append(list.get(i));
stringList.append((i < listSize - 1) ? ", " : " ");
}
return stringList.toString();
}
}

View file

@ -0,0 +1,189 @@
package io.github.sspanak.tt9.languages;
import android.content.res.AssetManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import io.github.sspanak.tt9.Logger;
public class LanguageDefinition {
private static final String languagesDir = "languages";
private static final String definitionsDir = languagesDir + "/definitions";
public String abcString = "";
public String dictionaryFile = "";
public boolean hasUpperCase = true;
public ArrayList<ArrayList<String>> layout = new ArrayList<>();
public String locale = "";
public String name = "";
/**
* getAllFiles
* Returns a list of the paths of all language definition files in the assets folder or an empty list on error.
*/
public static ArrayList<String> getAllFiles(AssetManager assets) {
ArrayList<String> files = new ArrayList<>();
try {
for (String file : assets.list(definitionsDir)) {
files.add(definitionsDir + "/" + file);
}
Logger.d("LanguageDefinition", "Found: " + files.size() + " languages.");
} catch (IOException | NullPointerException e) {
Logger.e("tt9.LanguageDefinition", "Failed reading language definitions from: '" + definitionsDir + "'. " + e.getMessage());
}
return files;
}
/**
* fromFile
* Takes the path to a language definition in the assets folder and parses that file into a LanguageDefinition
* or throws an IOException on error.
*/
public static LanguageDefinition fromFile(AssetManager assetManager, String definitionFile) throws IOException {
return parse(load(assetManager, definitionFile));
}
/**
* load
* Loads a language definition file from the assets folder into a String or throws an IOException on error.
*/
private static ArrayList<String> load(AssetManager assetManager, String definitionFile) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(assetManager.open(definitionFile), StandardCharsets.UTF_8));
ArrayList<String> fileContents = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) {
fileContents.add(line);
}
return fileContents;
}
/**
* parse
* Converts "yaml" to a LanguageDefinition object. All properties in the YAML are considered optional,
* so the LanguageDefinition defaults will be used when some property is omitted.
*
* Had to write all this, because the only usable library, SnakeYAML, works fine on Android 10+,
* but causes crashes on older devices.
*/
@NonNull
private static LanguageDefinition parse(ArrayList<String> yaml) {
LanguageDefinition definition = new LanguageDefinition();
String value;
value = getPropertyFromYaml(yaml, "abcString");
definition.abcString = value != null ? value : definition.abcString;
value = getPropertyFromYaml(yaml, "dictionaryFile");
definition.dictionaryFile = value != null ? value : definition.dictionaryFile;
value = getPropertyFromYaml(yaml, "locale");
definition.locale = value != null ? value : definition.locale;
value = getPropertyFromYaml(yaml, "name");
definition.name = value != null ? value : definition.name;
definition.layout = getLayoutFromYaml(yaml);
value = getPropertyFromYaml(yaml, "hasUpperCase");
if (value != null) {
value = value.toLowerCase();
definition.hasUpperCase = value.equals("true") || value.equals("on") || value.equals("yes") || value.equals("y");
}
return definition;
}
/**
* getPropertyFromYaml
* Finds "property" in the "yaml" and returns its value.
* Optional properties are allowed. NULL will be returned when they are missing.
*/
@Nullable
private static String getPropertyFromYaml(ArrayList<String> yaml, String property) {
for (String line : yaml) {
line = line.replaceAll("#.+$", "").trim();
String[] parts = line.split(":");
if (parts.length < 2) {
continue;
}
if (property.equals(parts[0].trim())) {
return parts[1].trim();
}
}
return null;
}
/**
* getLayoutFromYaml
* Finds and extracts the keypad layout. Less than 10 keys are accepted allowed leaving the ones up to 9-key empty.
*/
@NonNull
private static ArrayList<ArrayList<String>> getLayoutFromYaml(ArrayList<String> yaml) {
ArrayList<ArrayList<String>> layout = new ArrayList<>();
boolean inLayout = false;
for (int i = 0; i < yaml.size(); i++) {
if (yaml.get(i).contains("layout")) {
inLayout = true;
continue;
}
if (inLayout) {
ArrayList<String> lineChars = getLayoutEntryFromYamlLine(yaml.get(i));
if (lineChars != null) {
layout.add(lineChars);
} else {
break;
}
}
}
return layout;
}
/**
* getLayoutEntryFromYamlLine
* Validates a YAML line as an array and returns the character list to be assigned to a given key (a layout entry).
* If the YAML line is invalid, NULL will be returned.
*/
@Nullable
private static ArrayList<String> getLayoutEntryFromYamlLine(String yamlLine) {
if (!yamlLine.contains("[") || !yamlLine.contains("]")) {
return null;
}
String line = yamlLine
.replaceAll("#.+$", "")
.replace('-', ' ')
.replace('[', ' ')
.replace(']', ' ')
.replace(" ", "");
return new ArrayList<>(Arrays.asList(line.split(",")));
}
public String getDictionaryFile() {
return languagesDir + "/dictionaries/" + dictionaryFile;
}
}

View file

@ -0,0 +1,15 @@
package io.github.sspanak.tt9.languages;
import android.content.Context;
import java.util.Locale;
import io.github.sspanak.tt9.R;
public class NullLanguage extends Language {
public NullLanguage(Context context) {
locale = Locale.ROOT;
name = context.getString(R.string.no_language);
abcString = "abc";
}
}

View file

@ -0,0 +1,167 @@
package io.github.sspanak.tt9.preferences;
import android.os.Bundle;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.db.LegacyDb;
import io.github.sspanak.tt9.ime.helpers.GlobalKeyboardSettings;
import io.github.sspanak.tt9.ime.helpers.InputModeValidator;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
import io.github.sspanak.tt9.preferences.screens.AppearanceScreen;
import io.github.sspanak.tt9.preferences.screens.DebugScreen;
import io.github.sspanak.tt9.preferences.screens.DictionariesScreen;
import io.github.sspanak.tt9.preferences.screens.HotkeysScreen;
import io.github.sspanak.tt9.preferences.screens.KeyPadScreen;
import io.github.sspanak.tt9.preferences.screens.MainSettingsScreen;
import io.github.sspanak.tt9.preferences.screens.SetupScreen;
import io.github.sspanak.tt9.preferences.screens.UsageStatsScreen;
import io.github.sspanak.tt9.ui.DictionaryLoadingBar;
public class PreferencesActivity extends AppCompatActivity implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
public SettingsStore settings;
public GlobalKeyboardSettings globalKeyboardSettings;
@Override
protected void onCreate(Bundle savedInstanceState) {
globalKeyboardSettings = new GlobalKeyboardSettings(this, (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE));
settings = new SettingsStore(this);
applyTheme();
Logger.enableDebugLevel(settings.getDebugLogsEnabled());
try (LegacyDb db = new LegacyDb(this)) { db.clear(); }
WordStoreAsync.init(this);
InputModeValidator.validateEnabledLanguages(this, settings.getEnabledLanguageIds());
validateFunctionKeys();
super.onCreate(savedInstanceState);
// changing the theme causes onCreate(), which displays the MainSettingsScreen,
// but leaves the old "back" history, which is no longer valid,
// so we must reset it
getSupportFragmentManager().popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);
buildLayout();
}
@Override
public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller, @NonNull Preference pref) {
Fragment fragment = getScreen((getScreenName(pref)));
fragment.setArguments(pref.getExtras());
displayScreen(fragment, true);
return true;
}
/**
* getScreenName
* Determines the name of the screen for the given preference, as defined in the preference's "fragment" attribute.
* Expected format: "current.package.name.screens.SomeNameScreen"
*/
private String getScreenName(@NonNull Preference pref) {
String screenClassName = pref.getFragment();
return screenClassName != null ? screenClassName.replaceFirst("^.+?([^.]+)Screen$", "$1") : "";
}
/**
* getScreen
* Finds a screen fragment by name. If there is no fragment with such name, the main screen
* fragment will be returned.
*/
private Fragment getScreen(String name) {
switch (name) {
case "Appearance":
return new AppearanceScreen(this);
case "Debug":
return new DebugScreen(this);
case "Dictionaries":
return new DictionariesScreen(this);
case "Hotkeys":
return new HotkeysScreen(this);
case "KeyPad":
return new KeyPadScreen(this);
case "Setup":
return new SetupScreen(this);
case "SlowQueries":
return new UsageStatsScreen(this);
default:
return new MainSettingsScreen(this);
}
}
/**
* displayScreen
* Replaces the currently displayed screen fragment with a new one.
*/
private void displayScreen(Fragment screen, boolean addToBackStack) {
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
transaction.replace(R.id.preferences_container, screen);
if (addToBackStack) {
transaction.addToBackStack(screen.getClass().getSimpleName());
}
transaction.commit();
}
private void buildLayout() {
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayShowHomeEnabled(true);
actionBar.setDisplayHomeAsUpEnabled(true); // hide the "back" button, if visible
}
setContentView(R.layout.preferences_container);
displayScreen(getScreen("default"), false);
}
public void setScreenTitle(int title) {
// set the title
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setTitle(title);
}
}
private void applyTheme() {
AppCompatDelegate.setDefaultNightMode(settings.getTheme());
}
private void validateFunctionKeys() {
if (settings.areHotkeysInitialized()) {
Hotkeys.setDefault(settings);
}
}
public DictionaryLoadingBar getDictionaryProgressBar() {
return DictionaryLoadingBar.getInstance(this);
}
public DictionaryLoader getDictionaryLoader() {
return DictionaryLoader.getInstance(this);
}
}

View file

@ -0,0 +1,303 @@
package io.github.sspanak.tt9.preferences;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.PreferenceManager;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.items.SectionKeymap;
public class SettingsStore {
private final Context context;
private final SharedPreferences prefs;
private final SharedPreferences.Editor prefsEditor;
public SettingsStore(Context context) {
this.context = context;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefsEditor = prefs.edit();
}
/************* validators *************/
private boolean doesLanguageExist(int langId) {
return LanguageCollection.getLanguage(context, langId) != null;
}
private boolean validateSavedLanguage(int langId, String logTag) {
if (!doesLanguageExist(langId)) {
Logger.w(logTag, "Not saving invalid language with ID: " + langId);
return false;
}
return true;
}
@SuppressWarnings("SameParameterValue")
private boolean isIntInList(int number, ArrayList<Integer> list, String logTag, String logMsg) {
if (!list.contains(number)) {
Logger.w(logTag, logMsg);
return false;
}
return true;
}
/************* input settings *************/
public ArrayList<Integer> getEnabledLanguageIds() {
Set<String> languagesPref = getEnabledLanguagesIdsAsStrings();
ArrayList<Integer>languageIds = new ArrayList<>();
for (String languageId : languagesPref) {
languageIds.add(Integer.valueOf(languageId));
}
return languageIds;
}
public Set<String> getEnabledLanguagesIdsAsStrings() {
return prefs.getStringSet("pref_languages", new HashSet<>(Collections.singletonList(
String.valueOf(LanguageCollection.getDefault(context).getId())
)));
}
public void saveEnabledLanguageIds(ArrayList<Integer> languageIds) {
Set<String> idsAsStrings = new HashSet<>();
for (int langId : languageIds) {
idsAsStrings.add(String.valueOf(langId));
}
saveEnabledLanguageIds(idsAsStrings);
}
public void saveEnabledLanguageIds(Set<String> languageIds) {
Set<String> validLanguageIds = new HashSet<>();
for (String langId : languageIds) {
if (!validateSavedLanguage(Integer.parseInt(langId), "saveEnabledLanguageIds")){
continue;
}
validLanguageIds.add(langId);
}
if (validLanguageIds.size() == 0) {
Logger.w("saveEnabledLanguageIds", "Refusing to save an empty language list");
return;
}
prefsEditor.putStringSet("pref_languages", validLanguageIds);
prefsEditor.apply();
}
public int getTextCase() {
return prefs.getInt("pref_text_case", InputMode.CASE_LOWER);
}
public void saveTextCase(int textCase) {
boolean isTextCaseValid = isIntInList(
textCase,
new ArrayList<>(Arrays.asList(InputMode.CASE_CAPITALIZE, InputMode.CASE_LOWER, InputMode.CASE_UPPER)),
"saveTextCase",
"Not saving invalid text case: " + textCase
);
if (isTextCaseValid) {
prefsEditor.putInt("pref_text_case", textCase);
prefsEditor.apply();
}
}
public int getInputLanguage() {
return prefs.getInt("pref_input_language", LanguageCollection.getDefault(context).getId());
}
public void saveInputLanguage(int language) {
if (validateSavedLanguage(language, "saveInputLanguage")){
prefsEditor.putInt("pref_input_language", language);
prefsEditor.apply();
}
}
public int getInputMode() {
return prefs.getInt("pref_input_mode", InputMode.MODE_PREDICTIVE);
}
public void saveInputMode(int mode) {
boolean isModeValid = isIntInList(
mode,
new ArrayList<>(Arrays.asList(InputMode.MODE_123, InputMode.MODE_PREDICTIVE, InputMode.MODE_ABC)),
"saveInputMode",
"Not saving invalid input mode: " + mode
);
if (isModeValid) {
prefsEditor.putInt("pref_input_mode", mode);
prefsEditor.apply();
}
}
/************* function key settings *************/
public boolean areHotkeysInitialized() {
return !prefs.getBoolean("hotkeys_initialized", false);
}
public void setDefaultKeys(
int addWord,
int backspace,
int changeKeyboard,
int filterClear,
int filterSuggestions,
int previousSuggestion,
int nextSuggestion,
int nextInputMode,
int nextLanguage,
int showSettings
) {
prefsEditor
.putString(SectionKeymap.ITEM_ADD_WORD, String.valueOf(addWord))
.putString(SectionKeymap.ITEM_BACKSPACE, String.valueOf(backspace))
.putString(SectionKeymap.ITEM_CHANGE_KEYBOARD, String.valueOf(changeKeyboard))
.putString(SectionKeymap.ITEM_FILTER_CLEAR, String.valueOf(filterClear))
.putString(SectionKeymap.ITEM_FILTER_SUGGESTIONS, String.valueOf(filterSuggestions))
.putString(SectionKeymap.ITEM_PREVIOUS_SUGGESTION, String.valueOf(previousSuggestion))
.putString(SectionKeymap.ITEM_NEXT_SUGGESTION, String.valueOf(nextSuggestion))
.putString(SectionKeymap.ITEM_NEXT_INPUT_MODE, String.valueOf(nextInputMode))
.putString(SectionKeymap.ITEM_NEXT_LANGUAGE, String.valueOf(nextLanguage))
.putString(SectionKeymap.ITEM_SHOW_SETTINGS, String.valueOf(showSettings))
.putBoolean("hotkeys_initialized", true)
.apply();
}
public int getFunctionKey(String functionName) {
try {
return Integer.parseInt(prefs.getString(functionName, "0"));
} catch (NumberFormatException e) {
return 0;
}
}
public int getKeyAddWord() {
return getFunctionKey(SectionKeymap.ITEM_ADD_WORD);
}
public int getKeyBackspace() {
return getFunctionKey(SectionKeymap.ITEM_BACKSPACE);
}
public int getKeyChangeKeyboard() {
return getFunctionKey(SectionKeymap.ITEM_CHANGE_KEYBOARD);
}
public int getKeyFilterClear() {
return getFunctionKey(SectionKeymap.ITEM_FILTER_CLEAR);
}
public int getKeyFilterSuggestions() {
return getFunctionKey(SectionKeymap.ITEM_FILTER_SUGGESTIONS);
}
public int getKeyPreviousSuggestion() {
return getFunctionKey(SectionKeymap.ITEM_PREVIOUS_SUGGESTION);
}
public int getKeyNextSuggestion() {
return getFunctionKey(SectionKeymap.ITEM_NEXT_SUGGESTION);
}
public int getKeyNextInputMode() {
return getFunctionKey(SectionKeymap.ITEM_NEXT_INPUT_MODE);
}
public int getKeyNextLanguage() {
return getFunctionKey(SectionKeymap.ITEM_NEXT_LANGUAGE);
}
public int getKeyShowSettings() {
return getFunctionKey(SectionKeymap.ITEM_SHOW_SETTINGS);
}
/************* UI settings *************/
public boolean getDarkTheme() {
int theme = getTheme();
if (theme == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
return (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
} else {
return theme == AppCompatDelegate.MODE_NIGHT_YES;
}
}
public int getTheme() {
try {
return Integer.parseInt(prefs.getString("pref_theme", String.valueOf(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)));
} catch (NumberFormatException e) {
return AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM;
}
}
public boolean getShowSoftKeys() { return prefs.getBoolean("pref_show_soft_keys", true); }
public boolean getShowSoftNumpad() { return getShowSoftKeys() && prefs.getBoolean("pref_show_soft_numpad", false); }
/************* typing settings *************/
public int getAbcAutoAcceptTimeout() { return prefs.getBoolean("abc_auto_accept", true) ? 800 : -1; }
public boolean getAutoSpace() { return prefs.getBoolean("auto_space", true); }
public boolean getAutoTextCase() { return prefs.getBoolean("auto_text_case", true); }
public String getDoubleZeroChar() {
String character = prefs.getString("pref_double_zero_char", ".");
// SharedPreferences return a corrupted string when using the real "\n"... :(
return character.equals("\\n") ? "\n" : character;
}
public boolean getUpsideDownKeys() { return prefs.getBoolean("pref_upside_down_keys", false); }
/************* internal settings *************/
public boolean getDebugLogsEnabled() { return prefs.getBoolean("pref_enable_debug_logs", Logger.isDebugLevel()); }
public final static int DICTIONARY_IMPORT_BATCH_SIZE = 5000; // words
public final static int DICTIONARY_IMPORT_PROGRESS_UPDATE_TIME = 250; // ms
public final static int DICTIONARY_MISSING_WARNING_INTERVAL = 30000; // ms
public final static int PREFERENCES_CLICK_DEBOUNCE_TIME = 250; // ms
public final static byte SLOW_QUERY_TIME = 50; // ms
public final static int SOFT_KEY_REPEAT_DELAY = 40; // ms
public final static float SOFT_KEY_COMPLEX_LABEL_TITLE_SIZE = 0.55f;
public final static float SOFT_KEY_COMPLEX_LABEL_ARABIC_TITLE_SIZE = 0.72f;
public final static float SOFT_KEY_COMPLEX_LABEL_SUB_TITLE_SIZE = 0.8f;
public final static int SUGGESTIONS_MAX = 20;
public final static int SUGGESTIONS_MIN = 8;
public final static int SUGGESTIONS_SELECT_ANIMATION_DURATION = 66;
public final static int SUGGESTIONS_TRANSLATE_ANIMATION_DURATION = 0;
public final static int WORD_FREQUENCY_MAX = 25500;
public final static int WORD_FREQUENCY_NORMALIZATION_DIVIDER = 100; // normalized frequency = WORD_FREQUENCY_MAX / WORD_FREQUENCY_NORMALIZATION_DIVIDER
public final static int WORD_NORMALIZATION_DELAY = 120000; // ms
/************* hack settings *************/
public int getSuggestionScrollingDelay() {
return prefs.getBoolean("pref_alternative_suggestion_scrolling", false) ? 200 : 0;
}
public boolean getFbMessengerHack() {
return prefs.getBoolean("pref_hack_fb_messenger", false);
}
}

View file

@ -0,0 +1,179 @@
package io.github.sspanak.tt9.preferences.helpers;
import android.content.Context;
import android.content.res.Resources;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.ViewConfiguration;
import java.util.LinkedHashMap;
import java.util.Set;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class Hotkeys {
private final Context context;
private final Resources resources;
private final String holdKeyTranslation;
private final LinkedHashMap<String, String> KEYS = new LinkedHashMap<>();
public Hotkeys(Context context) {
this.context = context;
resources = context.getResources();
holdKeyTranslation = resources.getString(R.string.key_hold_key);
addNoKey();
generateList();
}
public String get(String key) {
return KEYS.get(key);
}
public Set<String> toSet() {
return KEYS.keySet();
}
/**
* setDefault
* Applies the default hotkey scheme.
*
* When a standard "Backspace" hardware key is available, "Backspace" hotkey association is not necessary,
* so it will be left out blank, to allow the hardware key do its job.
* When the on-screen keyboard is on, "Back" is also not associated, because it will cause weird user
* experience. Instead the on-screen "Backspace" key can be used.
*
* Arrow keys for manipulating suggestions are also assigned only if available.
*/
public static void setDefault(SettingsStore settings) {
int backspace = KeyEvent.KEYCODE_BACK;
if (
KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_CLEAR)
|| KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DEL)
|| settings.getShowSoftNumpad()
) {
backspace = 0;
}
int clearFilter = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DPAD_DOWN) && !settings.getShowSoftNumpad() ? KeyEvent.KEYCODE_DPAD_DOWN : 0;
int filter = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DPAD_UP) && !settings.getShowSoftNumpad() ? KeyEvent.KEYCODE_DPAD_UP : 0;
int nextSuggestion = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DPAD_RIGHT) && !settings.getShowSoftNumpad() ? KeyEvent.KEYCODE_DPAD_RIGHT : 0;
int previousSuggestion = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_DPAD_LEFT) && !settings.getShowSoftNumpad() ? KeyEvent.KEYCODE_DPAD_LEFT : 0;
settings.setDefaultKeys(
KeyEvent.KEYCODE_STAR,
backspace,
0, // "change keyboard" is unassigned by default
clearFilter,
filter,
previousSuggestion,
nextSuggestion,
KeyEvent.KEYCODE_POUND,
-KeyEvent.KEYCODE_POUND, // negative means "hold"
-KeyEvent.KEYCODE_STAR
);
}
/**
* addIfDeviceHasKey
* Add the key only if Android says the device has such keypad button or a permanent touch key.
*/
private void addIfDeviceHasKey(int code, String name, boolean allowHold) {
if (
(code == KeyEvent.KEYCODE_MENU && ViewConfiguration.get(context).hasPermanentMenuKey())
|| KeyCharacterMap.deviceHasKey(code)
) {
add(code, name, allowHold);
}
}
/**
* addIfDeviceHasKey
* Same as addIfDeviceHasKey, but accepts a Resource String as a key name.
*
*/
@SuppressWarnings("SameParameterValue")
private void addIfDeviceHasKey(int code, int nameResource, boolean allowHold) {
addIfDeviceHasKey(code, resources.getString(nameResource), allowHold);
}
/**
* add
* These key will be added as a selectable option, regardless if it exists or or not.
* No validation will be performed.
*/
private void add(int code, String name, boolean allowHold) {
KEYS.put(String.valueOf(code), name);
if (allowHold) {
KEYS.put(String.valueOf(-code), name + " " + holdKeyTranslation);
}
}
/**
* add
* Same as add(), but accepts a Resource String as a key name.
*/
@SuppressWarnings("SameParameterValue")
private void add(int code, int nameResource, boolean allowHold) {
add(code, resources.getString(nameResource), allowHold);
}
/**
* addNoKey
* This is the "--" option. The key code matches no key on the keypad.
*/
private void addNoKey() {
add(0, R.string.key_none, false);
}
/**
* generateList
* Generates a list of all supported hotkeys for associating functions in the Settings.
*
* NOTE: Some TT9 functions do not support all keys. Here you just list all possible options.
* Actual validation and assigning happens in SectionKeymap.populate().
*/
private void generateList() {
add(KeyEvent.KEYCODE_CALL, R.string.key_call, true);
addIfDeviceHasKey(KeyEvent.KEYCODE_BACK, R.string.key_back, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_F1, "F1", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_F2, "F2", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_F3, "F3", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_F4, "F4", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_MENU, R.string.key_menu, true);
addIfDeviceHasKey(KeyEvent.KEYCODE_SOFT_LEFT, R.string.key_soft_left, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_SOFT_RIGHT, R.string.key_soft_right, false);
add(KeyEvent.KEYCODE_POUND, "#", true);
add(KeyEvent.KEYCODE_STAR, "", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_DPAD_UP, R.string.key_dpad_up, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_DPAD_DOWN, R.string.key_dpad_down, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_DPAD_LEFT, R.string.key_dpad_left, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_DPAD_RIGHT, R.string.key_dpad_right, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_NUMPAD_ADD, "Num +", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_NUMPAD_SUBTRACT, "Num -", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_NUMPAD_MULTIPLY, "Num *", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_NUMPAD_DIVIDE, "Num /", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_NUMPAD_DOT, "Num .", true);
addIfDeviceHasKey(KeyEvent.KEYCODE_VOLUME_DOWN, R.string.key_volume_down, false);
addIfDeviceHasKey(KeyEvent.KEYCODE_VOLUME_UP, R.string.key_volume_up, false);
}
}

View file

@ -0,0 +1,83 @@
package io.github.sspanak.tt9.preferences.items;
import androidx.preference.Preference;
import java.util.ArrayList;
import java.util.List;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.preferences.SettingsStore;
abstract public class ItemClickable {
private long lastClickTime = 0;
protected final Preference item;
private final ArrayList<ItemClickable> otherItems = new ArrayList<>();
public ItemClickable(Preference item) {
this.item = item;
}
public void disable() {
item.setEnabled(false);
}
public void enable() {
item.setEnabled(true);
}
public void enableClickHandler() {
item.setOnPreferenceClickListener(this::debounceClick);
}
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
* Protection against faulty devices, that sometimes send two (or more) click events
* per a single key press.
*
* My smashed Qin F21 Pro+ occasionally does this, if I press the keys hard.
* There were reports the same happens on Kyocera KYF31, causing absolutely undesirable side effects.
* See: <a href="https://github.com/sspanak/tt9/issues/117">...</a>
*/
protected boolean debounceClick(Preference p) {
long now = System.currentTimeMillis();
if (now - lastClickTime < SettingsStore.PREFERENCES_CLICK_DEBOUNCE_TIME) {
Logger.d("debounceClick", "Preference click debounced.");
return true;
}
lastClickTime = now;
return onClick(p);
}
abstract protected boolean onClick(Preference p);
}

View file

@ -0,0 +1,70 @@
package io.github.sspanak.tt9.preferences.items;
import androidx.preference.DropDownPreference;
import androidx.preference.Preference;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import io.github.sspanak.tt9.Logger;
public class ItemDropDown {
private final DropDownPreference item;
private LinkedHashMap<Integer, String> values;
public ItemDropDown(DropDownPreference item) {
this.item = item;
}
protected void populate(LinkedHashMap<Integer, String> values) {
if (item == null) {
Logger.w("ItemDropDown.populate", "Cannot populate a NULL item. Ignoring.");
return;
}
this.values = values != null ? values : new LinkedHashMap<>();
ArrayList<String> keys = new ArrayList<>();
for (int key : this.values.keySet()) {
keys.add(String.valueOf(key));
}
item.setEntryValues(keys.toArray(new CharSequence[0]));
item.setEntries(this.values.values().toArray(new CharSequence[0]));
}
public ItemDropDown enableClickHandler() {
if (item == null) {
Logger.w("SectionKeymap.populateItem", "Cannot set a click listener a NULL item. Ignoring.");
return this;
}
item.setOnPreferenceChangeListener(this::onClick);
return this;
}
protected boolean onClick(Preference preference, Object newKey) {
try {
String previewValue = values.get(Integer.parseInt(newKey.toString()));
((DropDownPreference) preference).setValue(newKey.toString());
setPreview(previewValue);
return true;
} catch (NumberFormatException e) {
return false;
}
}
private void setPreview(String value) {
if (item != null) {
item.setSummary(value);
}
}
public void preview() {
try {
setPreview(values.get(Integer.parseInt(item.getValue())));
} catch (NumberFormatException e) {
setPreview("");
}
}
}

View file

@ -0,0 +1,94 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import android.os.Bundle;
import androidx.preference.Preference;
import java.util.ArrayList;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAlreadyRunningException;
import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.DictionaryLoadingBar;
import io.github.sspanak.tt9.ui.UI;
public class ItemLoadDictionary extends ItemClickable {
public static final String NAME = "dictionary_load";
private final Context context;
private final SettingsStore settings;
private final DictionaryLoader loader;
private final DictionaryLoadingBar progressBar;
public ItemLoadDictionary(Preference item, Context context, SettingsStore settings, DictionaryLoader loader, DictionaryLoadingBar progressBar) {
super(item);
this.context = context;
this.loader = loader;
this.progressBar = progressBar;
this.settings = settings;
loader.setOnStatusChange(this::onLoadingStatusChange);
refreshStatus();
}
public void refreshStatus() {
if (loader.isRunning()) {
setLoadingStatus();
} else {
setReadyStatus();
}
}
private void onLoadingStatusChange(Bundle status) {
progressBar.show(context, status);
item.setSummary(progressBar.getTitle() + " " + progressBar.getMessage());
if (progressBar.isCancelled()) {
setReadyStatus();
} else if (progressBar.isFailed()) {
setReadyStatus();
UI.toastFromAsync(context, progressBar.getMessage());
} else if (progressBar.isCompleted()) {
setReadyStatus();
UI.toastFromAsync(context, R.string.dictionary_loaded);
}
}
@Override
protected boolean onClick(Preference p) {
ArrayList<Language> languages = LanguageCollection.getAll(context, settings.getEnabledLanguageIds());
try {
setLoadingStatus();
loader.load(languages);
} catch (DictionaryImportAlreadyRunningException e) {
loader.stop();
setReadyStatus();
}
return true;
}
private void setLoadingStatus() {
disableOtherItems();
item.setTitle(context.getString(R.string.dictionary_cancel_load));
}
private void setReadyStatus() {
enableOtherItems();
item.setTitle(context.getString(R.string.dictionary_load_title));
item.setSummary(progressBar.isFailed() || progressBar.isCancelled() ? progressBar.getMessage() : "");
}
}

View file

@ -0,0 +1,35 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import androidx.preference.Preference;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
import io.github.sspanak.tt9.ui.UI;
public class ItemResetKeys extends ItemClickable {
public static final String NAME = "reset_keys";
private final Context context;
private final SectionKeymap dropdowns;
private final SettingsStore settings;
public ItemResetKeys(Preference item, Context context, SettingsStore settings, SectionKeymap dropdowns) {
super(item);
this.context = context;
this.dropdowns = dropdowns;
this.settings = settings;
}
@Override
protected boolean onClick(Preference p) {
Hotkeys.setDefault(settings);
dropdowns.reloadSettings();
UI.toast(context, R.string.function_reset_keys_done);
return true;
}
}

View file

@ -0,0 +1,27 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Intent;
import android.provider.Settings;
import androidx.preference.Preference;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
public class ItemSelectGlobalKeyboard extends ItemClickable {
private final Intent clickIntent;
private final PreferencesActivity activity;
public ItemSelectGlobalKeyboard(Preference item, PreferencesActivity prefs) {
super(item);
this.activity = prefs;
clickIntent = new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS);
clickIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
@Override
protected boolean onClick(Preference p) {
activity.startActivity(clickIntent);
return false;
}
}

View file

@ -0,0 +1,86 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import androidx.preference.MultiSelectListPreference;
import java.util.ArrayList;
import java.util.HashSet;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.UI;
public class ItemSelectLanguage {
public static final String NAME = "pref_languages";
private final Context context;
private final SettingsStore settings;
private final MultiSelectListPreference item;
public ItemSelectLanguage(Context context, MultiSelectListPreference multiSelect, SettingsStore settings) {
this.context = context;
this.item = multiSelect;
this.settings = settings;
}
public ItemSelectLanguage populate() {
if (item == null) {
return this;
}
ArrayList<Language> languages = LanguageCollection.getAll(context, true);
if (languages.isEmpty()) {
UI.alert(context, R.string.error, R.string.failed_loading_language_definitions);
// do not return, the MultiSelect component requires arrays, even if empty, otherwise it crashes
}
ArrayList<CharSequence> values = new ArrayList<>();
for (Language l : languages) {
values.add(String.valueOf(l.getId()));
}
ArrayList<String> keys = new ArrayList<>();
for (Language l : languages) {
keys.add(l.getName());
}
item.setEntries(keys.toArray(new CharSequence[0]));
item.setEntryValues(values.toArray(new CharSequence[0]));
item.setValues(settings.getEnabledLanguagesIdsAsStrings());
previewSelection();
return this;
}
public void enableValidation() {
if (item == null) {
return;
}
item.setOnPreferenceChangeListener((preference, newValue) -> {
@SuppressWarnings("unchecked") HashSet<String> newLanguages = (HashSet<String>) newValue;
if (newLanguages.size() == 0) {
newLanguages.add("1");
}
settings.saveEnabledLanguageIds(newLanguages);
item.setValues(settings.getEnabledLanguagesIdsAsStrings());
previewSelection();
// we validate and save manually above, so "false" disables automatic save
return false;
});
}
private void previewSelection() {
item.setSummary(
LanguageCollection.toString(LanguageCollection.getAll(context, settings.getEnabledLanguageIds(), true))
);
}
}

View file

@ -0,0 +1,43 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.preference.DropDownPreference;
import androidx.preference.Preference;
import java.util.LinkedHashMap;
import io.github.sspanak.tt9.R;
public class ItemSelectTheme extends ItemDropDown {
public static final String NAME = "pref_theme";
private final Context context;
public ItemSelectTheme(Context context, DropDownPreference item) {
super(item);
this.context = context;
}
public ItemDropDown populate() {
LinkedHashMap<Integer, String> themes = new LinkedHashMap<>();
themes.put(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, context.getString(R.string.pref_dark_theme_auto));
themes.put(AppCompatDelegate.MODE_NIGHT_NO, context.getString(R.string.pref_dark_theme_no));
themes.put(AppCompatDelegate.MODE_NIGHT_YES, context.getString(R.string.pref_dark_theme_yes));
super.populate(themes);
return this;
}
@Override
protected boolean onClick(Preference preference, Object newKey) {
if (super.onClick(preference, newKey)) {
AppCompatDelegate.setDefaultNightMode(Integer.parseInt(newKey.toString()));
return true;
}
return false;
}
}

View file

@ -0,0 +1,67 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import android.content.res.Resources;
import androidx.preference.DropDownPreference;
import java.util.LinkedHashMap;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
public class ItemSelectZeroKeyCharacter {
public static final String NAME = "pref_double_zero_char";
private final DropDownPreference item;
private final LinkedHashMap<String, String> KEYS = new LinkedHashMap<>();
public ItemSelectZeroKeyCharacter(DropDownPreference dropDown, Context context) {
this.item = dropDown;
Resources resources = context.getResources();
KEYS.put(".", resources.getString(R.string.char_dot));
KEYS.put(",", resources.getString(R.string.char_comma));
KEYS.put("\\n", resources.getString(R.string.char_newline)); // SharedPreferences return a corrupted string when using the real "\n"... :(
KEYS.put(" ", resources.getString(R.string.char_space));
}
public ItemSelectZeroKeyCharacter populate() {
if (item == null) {
Logger.w("ItemSelectZeroKeyChar.populate", "Cannot populate a NULL item. Ignoring.");
return this;
}
item.setEntries(KEYS.values().toArray(new CharSequence[0]));
item.setEntryValues(KEYS.keySet().toArray(new CharSequence[0]));
previewSelection(item.getValue());
return this;
}
public void activate() {
if (item == null) {
Logger.w("ItemSelectZeroKeyChar.activate", "Cannot set a click listener a NULL item. Ignoring.");
return;
}
item.setOnPreferenceChangeListener((preference, newChar) -> {
((DropDownPreference) preference).setValue(newChar.toString());
previewSelection(newChar.toString());
return true;
});
}
private void previewSelection(String newChar) {
if (item == null) {
return;
}
item.setSummary(KEYS.get(newChar));
}
}

View file

@ -0,0 +1,21 @@
package io.github.sspanak.tt9.preferences.items;
import androidx.preference.Preference;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.ui.UI;
public class ItemSetDefaultGlobalKeyboard extends ItemClickable {
private final PreferencesActivity activity;
public ItemSetDefaultGlobalKeyboard(Preference item, PreferencesActivity prefs) {
super(item);
this.activity = prefs;
}
@Override
protected boolean onClick(Preference p) {
UI.showChangeKeyboardDialog(activity);
return false;
}
}

View file

@ -0,0 +1,62 @@
package io.github.sspanak.tt9.preferences.items;
import androidx.preference.Preference;
import java.util.ArrayList;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.ui.UI;
public class ItemTruncateAll extends ItemClickable {
public static final String NAME = "dictionary_truncate";
protected final PreferencesActivity activity;
protected final DictionaryLoader loader;
public ItemTruncateAll(Preference item, PreferencesActivity activity, DictionaryLoader loader) {
super(item);
this.activity = activity;
this.loader = loader;
}
@Override
protected boolean onClick(Preference p) {
if (loader != null && loader.isRunning()) {
return false;
}
onStartDeleting();
ArrayList<Integer> languageIds = new ArrayList<>();
for (Language lang : LanguageCollection.getAll(activity, false)) {
languageIds.add(lang.getId());
}
WordStoreAsync.deleteWords(this::onFinishDeleting, languageIds);
return true;
}
protected void onStartDeleting() {
disableOtherItems();
disable();
item.setSummary(R.string.dictionary_truncating);
}
protected void onFinishDeleting() {
activity.runOnUiThread(() -> {
enableOtherItems();
item.setSummary("");
enable();
UI.toastFromAsync(activity, R.string.dictionary_truncated);
});
}
}

View file

@ -0,0 +1,47 @@
package io.github.sspanak.tt9.preferences.items;
import androidx.preference.Preference;
import java.util.ArrayList;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class ItemTruncateUnselected extends ItemTruncateAll {
public static final String NAME = "dictionary_truncate_unselected";
private final SettingsStore settings;
public ItemTruncateUnselected(Preference item, PreferencesActivity context, SettingsStore settings, DictionaryLoader loader) {
super(item, context, loader);
this.settings = settings;
}
@Override
protected boolean onClick(Preference p) {
if (loader != null && loader.isRunning()) {
return false;
}
ArrayList<Integer> unselectedLanguageIds = new ArrayList<>();
ArrayList<Integer> selectedLanguageIds = settings.getEnabledLanguageIds();
for (Language lang : LanguageCollection.getAll(activity, false)) {
if (!selectedLanguageIds.contains(lang.getId())) {
unselectedLanguageIds.add(lang.getId());
}
}
onStartDeleting();
WordStoreAsync.deleteWords(this::onFinishDeleting, unselectedLanguageIds);
return true;
}
}

View file

@ -0,0 +1,151 @@
package io.github.sspanak.tt9.preferences.items;
import android.content.Context;
import androidx.preference.DropDownPreference;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Objects;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
public class SectionKeymap {
public static final String ITEM_ADD_WORD = "key_add_word";
public static final String ITEM_BACKSPACE = "key_backspace";
public static final String ITEM_CHANGE_KEYBOARD = "key_change_keyboard";
public static final String ITEM_FILTER_CLEAR = "key_filter_clear";
public static final String ITEM_FILTER_SUGGESTIONS = "key_filter_suggestions";
public static final String ITEM_PREVIOUS_SUGGESTION = "key_previous_suggestion";
public static final String ITEM_NEXT_SUGGESTION = "key_next_suggestion";
public static final String ITEM_NEXT_INPUT_MODE = "key_next_input_mode";
public static final String ITEM_NEXT_LANGUAGE = "key_next_language";
public static final String ITEM_SHOW_SETTINGS = "key_show_settings";
private final Hotkeys hotkeys;
private final Collection<DropDownPreference> items;
private final SettingsStore settings;
public SectionKeymap(Collection<DropDownPreference> dropDowns, Context context, SettingsStore settings) {
items = dropDowns;
hotkeys = new Hotkeys(context);
this.settings = settings;
}
public void reloadSettings() {
for (DropDownPreference dropDown : items) {
int keypadKey = settings.getFunctionKey(dropDown.getKey());
dropDown.setValue(String.valueOf(keypadKey));
}
populate();
}
public SectionKeymap populate() {
populateOtherItems(null);
return this;
}
public void activate() {
for (DropDownPreference item : items) {
onItemClick(item);
}
}
private void populateOtherItems(DropDownPreference itemToSkip) {
for (DropDownPreference item : items) {
if (itemToSkip != null && item != null && Objects.equals(itemToSkip.getKey(), item.getKey())) {
continue;
}
populateItem(item);
previewCurrentKey(item);
}
}
private void populateItem(DropDownPreference dropDown) {
if (dropDown == null) {
Logger.w("SectionKeymap.populateItem", "Cannot populate a NULL item. Ignoring.");
return;
}
ArrayList<String> keys = new ArrayList<>();
for (String key : hotkeys.toSet()) {
if (
validateKey(dropDown, String.valueOf(key))
// backspace works both when pressed short and long,
// so separate "hold" and "not hold" options for it make no sense
&& !(dropDown.getKey().equals(ITEM_BACKSPACE) && Integer.parseInt(key) < 0)
) {
keys.add(String.valueOf(key));
}
}
ArrayList<String> values = new ArrayList<>();
for (String key : keys) {
values.add(hotkeys.get(key));
}
dropDown.setEntries(values.toArray(new CharSequence[0]));
dropDown.setEntryValues(keys.toArray(new CharSequence[0]));
}
private void onItemClick(DropDownPreference item) {
if (item == null) {
Logger.w("SectionKeymap.populateItem", "Cannot set a click listener a NULL item. Ignoring.");
return;
}
item.setOnPreferenceChangeListener((preference, newKey) -> {
if (!validateKey((DropDownPreference) preference, newKey.toString())) {
return false;
}
((DropDownPreference) preference).setValue(newKey.toString());
previewCurrentKey((DropDownPreference) preference, newKey.toString());
populateOtherItems((DropDownPreference) preference);
return true;
});
}
private void previewCurrentKey(DropDownPreference dropDown) {
previewCurrentKey(dropDown, dropDown.getValue());
}
private void previewCurrentKey(DropDownPreference dropDown, String key) {
if (dropDown == null) {
return;
}
dropDown.setSummary(hotkeys.get(key));
}
private boolean validateKey(DropDownPreference dropDown, String key) {
if (dropDown == null || key == null) {
return false;
}
if (key.equals("0")) {
return true;
}
for (DropDownPreference item : items) {
if (item != null && !dropDown.getKey().equals(item.getKey()) && key.equals(item.getValue())) {
Logger.i("SectionKeymap.validateKey", "Key: '" + key + "' is already in use for function: " + item.getKey());
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,21 @@
package io.github.sspanak.tt9.preferences.screens;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.ItemSelectTheme;
public class AppearanceScreen extends BaseScreenFragment {
public AppearanceScreen() { init(); }
public AppearanceScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_appearance; }
@Override protected int getXml() { return R.xml.prefs_screen_appearance; }
@Override
protected void onCreate() {
(new ItemSelectTheme(activity, findPreference(ItemSelectTheme.NAME)))
.populate()
.enableClickHandler()
.preview();
}
}

View file

@ -0,0 +1,75 @@
package io.github.sspanak.tt9.preferences.screens;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceFragmentCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
abstract class BaseScreenFragment extends PreferenceFragmentCompat {
protected PreferencesActivity activity;
protected void init(PreferencesActivity activity) {
this.activity = activity;
init();
}
protected void init() {
if (activity == null) {
activity = (PreferencesActivity) getActivity();
setScreenTitle();
}
}
private void setScreenTitle() {
if (activity != null) {
activity.setScreenTitle(getTitle());
}
}
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setHasOptionsMenu(true); // enable "back" in "onOptionsItemSelected()"
setPreferencesFromResource(getXml(), rootKey);
if (activity == null) {
Logger.w(
"MainSettingsScreen",
"Starting up without an Activity. Preference Items will not be fully initialized."
);
return;
}
onCreate();
}
@Override
public void onResume() {
super.onResume();
setScreenTitle();
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home && activity != null && !super.onOptionsItemSelected(item)) {
activity.onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
abstract protected int getTitle();
abstract protected int getXml();
abstract protected void onCreate();
}

View file

@ -0,0 +1,121 @@
package io.github.sspanak.tt9.preferences.screens;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.os.Build;
import androidx.preference.Preference;
import androidx.preference.SwitchPreferenceCompat;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.ui.UI;
public class DebugScreen extends BaseScreenFragment {
private final static String LOG_TAG = "DebugScreen";
private final static String DEBUG_LOGS_SWITCH = "pref_enable_debug_logs";
private final static String SYSTEM_LOGS_SWITCH = "pref_enable_system_logs";
private final static String LOGS_CONTAINER = "debug_logs_container";
public DebugScreen() { init(); }
public DebugScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_debug_options; }
@Override protected int getXml() { return R.xml.prefs_screen_debug; }
@Override
protected void onCreate() {
initLogMessagesSwitch();
initSystemLogsSwitch();
enableLogsCopy();
SwitchPreferenceCompat systemLogs = findPreference(SYSTEM_LOGS_SWITCH);
boolean includeSystemLogs = systemLogs != null && systemLogs.isChecked();
printLogs(includeSystemLogs);
}
private void initLogMessagesSwitch() {
SwitchPreferenceCompat msgSwitch = findPreference(DEBUG_LOGS_SWITCH);
if (msgSwitch == null) {
Logger.w(LOG_TAG, "Debug logs switch not found.");
return;
}
msgSwitch.setChecked(Logger.isDebugLevel());
msgSwitch.setOnPreferenceChangeListener((Preference p, Object newValue) -> {
Logger.enableDebugLevel((boolean) newValue);
return true;
});
}
private void initSystemLogsSwitch() {
SwitchPreferenceCompat systemLogs = findPreference(SYSTEM_LOGS_SWITCH);
if (systemLogs == null) {
Logger.w(LOG_TAG, "System logs switch not found.");
return;
}
systemLogs.setOnPreferenceChangeListener((p, newValue) -> {
printLogs((boolean) newValue);
return true;
});
}
private void printLogs(boolean includeSystemLogs) {
Preference logsContainer = findPreference(LOGS_CONTAINER);
if (logsContainer == null) {
Logger.w(LOG_TAG, "Logs container not found. Cannot print logs");
return;
}
StringBuilder log = new StringBuilder();
try {
Process process = Runtime.getRuntime().exec("logcat -d -v threadtime io.github.sspanak.tt9:D");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = bufferedReader.readLine()) != null) {
if (includeSystemLogs || line.contains(Logger.TAG_PREFIX)) {
log.append(line).append("\n\n");
}
}
}
catch (IOException e) {
log.append("Error getting the logs. ").append(e.getMessage());
}
if (log.toString().isEmpty()) {
log.append("No Logs");
}
logsContainer.setSummary(log.toString());
}
private void enableLogsCopy() {
if (activity == null) {
Logger.w(LOG_TAG, "Activity is missing. Copying the logs will not be possible.");
return;
}
Preference logsContainer = findPreference(LOGS_CONTAINER);
if (logsContainer == null) {
Logger.w(LOG_TAG, "Logs container not found. Copying the logs will not be possible.");
return;
}
ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
logsContainer.setOnPreferenceClickListener((Preference p) -> {
clipboard.setPrimaryClip(ClipData.newPlainText("TT9 debug log", p.getSummary()));
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
UI.toast(activity, "Logs copied.");
}
return true;
});
}
}

View file

@ -0,0 +1,61 @@
package io.github.sspanak.tt9.preferences.screens;
import java.util.Arrays;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.ItemLoadDictionary;
import io.github.sspanak.tt9.preferences.items.ItemSelectLanguage;
import io.github.sspanak.tt9.preferences.items.ItemTruncateAll;
import io.github.sspanak.tt9.preferences.items.ItemTruncateUnselected;
public class DictionariesScreen extends BaseScreenFragment {
private ItemLoadDictionary loadItem;
public DictionariesScreen() { init(); }
public DictionariesScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_choose_languages; }
@Override protected int getXml() { return R.xml.prefs_screen_dictionaries; }
@Override
protected void onCreate() {
ItemSelectLanguage multiSelect = new ItemSelectLanguage(
activity,
findPreference(ItemSelectLanguage.NAME),
activity.settings
);
multiSelect.populate().enableValidation();
loadItem = new ItemLoadDictionary(
findPreference(ItemLoadDictionary.NAME),
activity,
activity.settings,
activity.getDictionaryLoader(),
activity.getDictionaryProgressBar()
);
ItemTruncateUnselected deleteItem = new ItemTruncateUnselected(
findPreference(ItemTruncateUnselected.NAME),
activity,
activity.settings,
activity.getDictionaryLoader()
);
ItemTruncateAll truncateItem = new ItemTruncateAll(
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

@ -0,0 +1,39 @@
package io.github.sspanak.tt9.preferences.screens;
import androidx.preference.DropDownPreference;
import java.util.Arrays;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.items.ItemResetKeys;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.SectionKeymap;
public class HotkeysScreen extends BaseScreenFragment {
public HotkeysScreen() { init(); }
public HotkeysScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_function_keys; }
@Override protected int getXml() { return R.xml.prefs_screen_hotkeys; }
@Override
public void onCreate() {
DropDownPreference[] dropDowns = {
findPreference(SectionKeymap.ITEM_ADD_WORD),
findPreference(SectionKeymap.ITEM_BACKSPACE),
findPreference(SectionKeymap.ITEM_CHANGE_KEYBOARD),
findPreference(SectionKeymap.ITEM_FILTER_CLEAR),
findPreference(SectionKeymap.ITEM_FILTER_SUGGESTIONS),
findPreference(SectionKeymap.ITEM_PREVIOUS_SUGGESTION),
findPreference(SectionKeymap.ITEM_NEXT_SUGGESTION),
findPreference(SectionKeymap.ITEM_NEXT_INPUT_MODE),
findPreference(SectionKeymap.ITEM_NEXT_LANGUAGE),
findPreference(SectionKeymap.ITEM_SHOW_SETTINGS),
};
SectionKeymap section = new SectionKeymap(Arrays.asList(dropDowns), activity, activity.settings);
section.populate().activate();
(new ItemResetKeys(findPreference(ItemResetKeys.NAME), activity, activity.settings, section))
.enableClickHandler();
}
}

View file

@ -0,0 +1,18 @@
package io.github.sspanak.tt9.preferences.screens;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.ItemSelectZeroKeyCharacter;
public class KeyPadScreen extends BaseScreenFragment {
public KeyPadScreen() { init(); }
public KeyPadScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_keypad; }
@Override protected int getXml() { return R.xml.prefs_screen_keypad; }
@Override
protected void onCreate() {
(new ItemSelectZeroKeyCharacter(findPreference(ItemSelectZeroKeyCharacter.NAME), activity)).populate().activate();
}
}

View file

@ -0,0 +1,102 @@
package io.github.sspanak.tt9.preferences.screens;
import android.content.Intent;
import android.net.Uri;
import androidx.preference.Preference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.regex.Pattern;
import io.github.sspanak.tt9.BuildConfig;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
public class MainSettingsScreen extends BaseScreenFragment {
private final Pattern releaseVersionRegex = Pattern.compile("^\\d+\\.\\d+$");
public MainSettingsScreen() { init(); }
public MainSettingsScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.app_settings;}
@Override protected int getXml() { return R.xml.prefs; }
@Override
public void onCreate() {
createSettingsSection();
addHelpLink();
createAboutSection();
}
@Override
public void onResume() {
init(); // changing the theme recreates the PreferencesActivity, making "this.activity" NULL, so we reinitialize it.
super.onResume();
createSettingsSection();
}
private void addHelpLink() {
try {
if (!releaseVersionRegex.matcher(BuildConfig.VERSION_NAME).find()) {
throw new Exception("VERSION_NAME does not match: \\d+.\\d+");
}
Preference helpSection = findPreference("help");
if (helpSection == null) {
throw new Exception("Could not find Help Preference");
}
String majorVersion = BuildConfig.VERSION_NAME.substring(0, BuildConfig.VERSION_NAME.indexOf('.'));
String versionedHelpUrl = getString(R.string.help_url).replace("blob/master", "blob/v" + majorVersion + ".0");
Intent intent = new Intent();
intent.setAction("android.intent.action.VIEW");
intent.setData(Uri.parse(versionedHelpUrl));
helpSection.setIntent(intent);
} catch (Exception e) {
Logger.w("MainSettingsScreen", "Could not set versioned help URL. Falling back to the default. " + e.getMessage());
}
}
private void createAboutSection() {
Preference vi = findPreference("version_info");
if (vi != null) {
vi.setSummary(BuildConfig.VERSION_FULL);
}
Preference donate = findPreference("donate_link");
if (donate != null) {
String appName = getString(R.string.app_name_short);
String url = getString(R.string.donate_url_short);
donate.setSummary(getString(R.string.donate_summary, appName, url));
}
}
private void createSettingsSection() {
boolean isTT9Enabled = activity.globalKeyboardSettings.isTT9Enabled();
Preference gotoSetup = findPreference("screen_setup");
if (gotoSetup != null) {
gotoSetup.setSummary(isTT9Enabled ? "" : activity.getString(R.string.setup_click_here_to_enable));
}
ArrayList<Preference> screens = new ArrayList<>(Arrays.asList(
findPreference("screen_appearance"),
findPreference("screen_dictionaries"),
findPreference("screen_keypad")
));
for (Preference goToScreen : screens) {
if (goToScreen != null) {
goToScreen.setEnabled(isTT9Enabled);
}
}
}
}

View file

@ -0,0 +1,45 @@
package io.github.sspanak.tt9.preferences.screens;
import androidx.preference.Preference;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.preferences.items.ItemSelectGlobalKeyboard;
import io.github.sspanak.tt9.preferences.items.ItemSetDefaultGlobalKeyboard;
public class SetupScreen extends BaseScreenFragment {
public SetupScreen() { init(); }
public SetupScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_setup;}
@Override protected int getXml() { return R.xml.prefs_screen_setup; }
@Override
public void onCreate() {
createKeyboardSection();
}
@Override
public void onResume() {
super.onResume();
createKeyboardSection();
}
private void createKeyboardSection() {
boolean isTT9On = activity.globalKeyboardSettings.isTT9Enabled();
Preference statusItem = findPreference("global_tt9_status");
if (statusItem != null) {
statusItem.setSummary(
isTT9On ? R.string.setup_tt9_on : R.string.setup_tt9_off
);
new ItemSelectGlobalKeyboard(statusItem, activity).enableClickHandler();
}
Preference defaultKeyboardItem = findPreference("global_default_keyboard");
if (defaultKeyboardItem != null) {
new ItemSetDefaultGlobalKeyboard(defaultKeyboardItem, activity).enableClickHandler();
}
}
}

View file

@ -0,0 +1,74 @@
package io.github.sspanak.tt9.preferences.screens;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.os.Build;
import androidx.preference.Preference;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.SlowQueryStats;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
import io.github.sspanak.tt9.ui.UI;
public class UsageStatsScreen extends BaseScreenFragment {
private final static String RESET_BUTTON = "pref_slow_queries_reset_stats";
private final static String SUMMARY_CONTAINER = "summary_container";
private final static String QUERY_LIST_CONTAINER = "query_list_container";
public UsageStatsScreen() { init(); }
public UsageStatsScreen(PreferencesActivity activity) { init(activity); }
@Override protected int getTitle() { return R.string.pref_category_usage_stats; }
@Override protected int getXml() { return R.xml.prefs_screen_usage_stats; }
@Override
protected void onCreate() {
printSummary();
printSlowQueries();
enableLogsCopy();
Preference resetButton = findPreference(RESET_BUTTON);
if (resetButton != null) {
resetButton.setOnPreferenceClickListener((Preference p) -> {
SlowQueryStats.clear();
printSummary();
printSlowQueries();
return true;
});
}
}
private void printSummary() {
Preference logsContainer = findPreference(SUMMARY_CONTAINER);
if (logsContainer != null) {
logsContainer.setSummary(SlowQueryStats.getSummary());
}
}
private void printSlowQueries() {
Preference queryListContainer = findPreference(QUERY_LIST_CONTAINER);
if (queryListContainer != null) {
String slowQueries = SlowQueryStats.getList();
queryListContainer.setSummary(slowQueries.isEmpty() ? "No slow queries." : slowQueries);
}
}
private void enableLogsCopy() {
Preference queryListContainer = findPreference(QUERY_LIST_CONTAINER);
if (activity == null || queryListContainer == null) {
return;
}
ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
queryListContainer.setOnPreferenceClickListener((Preference p) -> {
clipboard.setPrimaryClip(ClipData.newPlainText("TT9 debug log", p.getSummary()));
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
UI.toast(activity, "Logs copied.");
}
return true;
});
}
}

View file

@ -0,0 +1,109 @@
package io.github.sspanak.tt9.ui;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.WordStoreAsync;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
public class AddWordAct extends AppCompatActivity {
public static final int CODE_SUCCESS = 0;
public static final int CODE_BLANK_WORD = 1;
public static final int CODE_INVALID_LANGUAGE = 2;
public static final int CODE_WORD_EXISTS = 3;
public static final int CODE_GENERAL_ERROR = 666;
public static final String INTENT_FILTER = "tt9.add_word";
private Language language;
private String word;
@Override
protected void onCreate(Bundle savedData) {
super.onCreate(savedData);
readInput();
render(getMessage());
}
private void readInput() {
Intent i = getIntent();
word = i.getStringExtra("io.github.sspanak.tt9.word");
language = LanguageCollection.getLanguage(this, i.getIntExtra("io.github.sspanak.tt9.lang", -1));
}
private String getMessage() {
if (language == null) {
Logger.e("WordManager.confirmAddWord", "Cannot insert a word for NULL language");
UI.toastLong(getApplicationContext(), R.string.add_word_invalid_language);
return null;
}
return getString(R.string.add_word_confirm, word, language.getName());
}
private void render(String message) {
if (message == null || word == null || word.isEmpty()) {
finish();
return;
}
View main = View.inflate(this, R.layout.addwordview, null);
((TextView) main.findViewById(R.id.add_word_dialog_text)).append(message);
setContentView(main);
}
private void onAddedWord(int statusCode) {
String message;
switch (statusCode) {
case CODE_SUCCESS:
message = getString(R.string.add_word_success, word);
break;
case CODE_WORD_EXISTS:
message = getResources().getString(R.string.add_word_exist, word);
break;
case CODE_BLANK_WORD:
message = getString(R.string.add_word_blank);
break;
case CODE_INVALID_LANGUAGE:
message = getResources().getString(R.string.add_word_invalid_language);
break;
default:
message = getString(R.string.error_unexpected);
break;
}
finish();
sendMessageToMain(message);
}
public void addWord(View v) {
WordStoreAsync.put(this::onAddedWord, language, word);
}
private void sendMessageToMain(String message) {
Intent intent = new Intent(this, TraditionalT9.class);
intent.putExtra(INTENT_FILTER, message);
startService(intent);
}
public void cancelAddingWord(View v) {
finish();
}
}

View file

@ -0,0 +1,226 @@
package io.github.sspanak.tt9.ui;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import androidx.core.app.NotificationCompat;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Locale;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportException;
import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.InvalidLanguageException;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
public class DictionaryLoadingBar {
private static DictionaryLoadingBar self;
private static final int NOTIFICATION_ID = 1;
private static final String NOTIFICATION_CHANNEL_ID = "loading-notifications";
private final NotificationManager manager;
private final NotificationCompat.Builder notificationBuilder;
private final Resources resources;
private boolean isStopped = false;
private boolean hasFailed = false;
private int maxProgress = 0;
private int progress = 0;
private String title = "";
private String message = "";
public static DictionaryLoadingBar getInstance(Context context) {
if (self == null) {
self = new DictionaryLoadingBar(context);
}
return self;
}
public DictionaryLoadingBar(Context context) {
resources = context.getResources();
manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(new NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Dictionary Status",
NotificationManager.IMPORTANCE_LOW
));
notificationBuilder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);
} else {
//noinspection deprecation
notificationBuilder = new NotificationCompat.Builder(context);
}
notificationBuilder
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setOnlyAlertOnce(true);
}
public void setFileCount(int count) {
maxProgress = count * 100;
}
public boolean isCancelled() {
return isStopped;
}
public boolean isCompleted() {
return progress >= maxProgress;
}
public boolean isFailed() {
return hasFailed;
}
public String getTitle() {
return title;
}
public String getMessage() {
return message;
}
public void show(Context context, Bundle data) {
String error = data.getString("error", null);
int fileCount = data.getInt("fileCount", -1);
int progress = data.getInt("progress", -1);
if (error != null) {
hasFailed = true;
showError(
context,
error,
data.getInt("languageId", -1),
data.getLong("fileLine", -1),
data.getString("word", "")
);
} else if (progress >= 0) {
hasFailed = false;
if (fileCount >= 0) {
setFileCount(fileCount);
}
showProgress(
context,
data.getLong("time", 0),
data.getInt("currentFile", 0),
data.getInt("progress", 0),
data.getInt("languageId", -1)
);
}
}
private String generateTitle(Context context, int languageId) {
Language lang = LanguageCollection.getLanguage(context, languageId);
if (lang != null) {
return resources.getString(R.string.dictionary_loading, lang.getName());
}
return resources.getString(R.string.dictionary_loading_indeterminate);
}
private void showProgress(Context context, long time, int currentFile, int currentFileProgress, int languageId) {
if (currentFileProgress <= 0) {
hide();
isStopped = true;
title = "";
message = resources.getString(R.string.dictionary_load_cancelled);
return;
}
isStopped = false;
progress = 100 * currentFile + currentFileProgress;
if (progress >= maxProgress) {
progress = maxProgress = 0;
title = generateTitle(context, -1);
String timeFormat = time > 60000 ? " (%1.0fs)" : " (%1.1fs)";
message = resources.getString(R.string.completed) + String.format(Locale.ENGLISH, timeFormat, time / 1000.0);
} else {
title = generateTitle(context, languageId);
message = currentFileProgress + "%";
}
renderProgress();
}
private void showError(Context context, String errorType, int langId, long line, String word) {
Language lang = LanguageCollection.getLanguage(context, langId);
if (lang == null || errorType.equals(InvalidLanguageException.class.getSimpleName())) {
message = resources.getString(R.string.add_word_invalid_language);
} else if (errorType.equals(DictionaryImportException.class.getSimpleName()) || errorType.equals(InvalidLanguageCharactersException.class.getSimpleName())) {
message = resources.getString(R.string.dictionary_load_bad_char, word, line, lang.getName());
} else if (errorType.equals(IOException.class.getSimpleName()) || errorType.equals(FileNotFoundException.class.getSimpleName())) {
message = resources.getString(R.string.dictionary_not_found, lang.getName());
} else {
message = resources.getString(R.string.dictionary_load_error, lang.getName(), errorType);
}
title = generateTitle(context, -1);
progress = maxProgress = 0;
renderError();
}
private void hide() {
progress = maxProgress = 0;
manager.cancel(NOTIFICATION_ID);
}
private void renderError() {
NotificationCompat.BigTextStyle bigMessage = new NotificationCompat.BigTextStyle();
bigMessage.setBigContentTitle(title);
bigMessage.bigText(message);
notificationBuilder
.setSmallIcon(android.R.drawable.stat_notify_error)
.setStyle(bigMessage)
.setOngoing(false)
.setProgress(maxProgress, progress, false);
manager.notify(NOTIFICATION_ID, notificationBuilder.build());
}
private void renderProgress() {
notificationBuilder
.setSmallIcon(isCompleted() ? R.drawable.ic_done : android.R.drawable.stat_notify_sync)
.setOngoing(!isCompleted())
.setProgress(maxProgress, progress, false)
.setContentTitle(title)
.setContentText(message);
manager.notify(NOTIFICATION_ID, notificationBuilder.build());
}
}

View file

@ -0,0 +1,83 @@
package io.github.sspanak.tt9.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import android.view.inputmethod.InputMethodManager;
import android.widget.Toast;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.preferences.PreferencesActivity;
public class UI {
public static void showAddWordDialog(TraditionalT9 tt9, int language, String currentWord) {
Intent awIntent = new Intent(tt9, AddWordAct.class);
awIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
awIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
awIntent.putExtra("io.github.sspanak.tt9.word", currentWord);
awIntent.putExtra("io.github.sspanak.tt9.lang", language);
tt9.startActivity(awIntent);
}
public static void showChangeKeyboardDialog(Context context) {
((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE)).showInputMethodPicker();
}
public static void showSettingsScreen(TraditionalT9 tt9) {
Intent prefIntent = new Intent(tt9, PreferencesActivity.class);
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
tt9.hideWindow();
tt9.startActivity(prefIntent);
}
public static void alert(Context context, int titleResource, int messageResource) {
new AlertDialog.Builder(context)
.setTitle(titleResource)
.setMessage(messageResource)
.setCancelable(false)
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.cancel())
.show();
}
public static void toast(Context context, CharSequence msg) {
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);
}
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);
}
public static void toastLong(Context context, int resourceId) {
Toast.makeText(context, resourceId, Toast.LENGTH_LONG).show();
}
public static void toastLong(Context context, CharSequence msg) {
Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
}
@Deprecated
public static void toastLongFromAsync(Context context, CharSequence msg) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
toastLong(context, msg);
}
}

View file

@ -0,0 +1,80 @@
package io.github.sspanak.tt9.ui.main;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.ui.main.keys.SoftKey;
abstract class BaseMainLayout {
protected final TraditionalT9 tt9;
private final int xml;
protected View view = null;
protected ArrayList<SoftKey> keys = new ArrayList<>();
BaseMainLayout(TraditionalT9 tt9, int xml) {
this.tt9 = tt9;
this.xml = xml;
}
/** setDarkTheme
* Changes the main view colors according to the theme.
*
* We need to do this manually, instead of relying on the Context to resolve the appropriate colors,
* because this View is part of the main service View. And service Views are always locked to the
* system context and theme.
*
* More info:
* <a href="https://stackoverflow.com/questions/72382886/system-applies-night-mode-to-views-added-in-service-type-application-overlay">...</a>
*/
abstract public void setDarkTheme(boolean yes);
/**
* render
* Do all the necessary stuff to display the View.
*/
abstract public void render();
/**
* getKeys
* Returns a list of all the usable Soft Keys.
*/
abstract protected ArrayList<SoftKey> getKeys();
public View getView() {
if (view == null) {
view = View.inflate(tt9.getApplicationContext(), xml, null);
}
return view;
}
public void enableClickHandlers() {
for (SoftKey key : getKeys()) {
key.setTT9(tt9);
}
}
protected ArrayList<SoftKey> getKeysFromContainer(ViewGroup container) {
ArrayList<SoftKey> keyList = new ArrayList<>();
final int childrenCount = container != null ? container.getChildCount() : 0;
for (int i = 0; i < childrenCount; i++) {
View child = container.getChildAt(i);
if (child instanceof SoftKey) {
keyList.add((SoftKey) child);
}
}
return keyList;
}
}

View file

@ -0,0 +1,98 @@
package io.github.sspanak.tt9.ui.main;
import android.view.View;
import android.view.ViewGroup;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.Arrays;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.ui.main.keys.SoftKey;
class MainLayoutNumpad extends BaseMainLayout {
MainLayoutNumpad(TraditionalT9 tt9) {
super(tt9, R.layout.main_numpad);
}
@Override
public void setDarkTheme(boolean darkEnabled) {
if (view == null) {
return;
}
// background
view.setBackground(ContextCompat.getDrawable(
view.getContext(),
darkEnabled ? R.color.dark_numpad_background : R.color.numpad_background
));
// text
for (SoftKey key : getKeys()) {
key.setDarkTheme(darkEnabled);
}
// separators
int separatorColor = ContextCompat.getColor(
view.getContext(),
darkEnabled ? R.color.dark_numpad_separator : R.color.numpad_separator
);
for (View separator : getSeparators()) {
if (separator != null) {
separator.setBackgroundColor(separatorColor);
}
}
}
@Override
public void render() {
getView();
enableClickHandlers();
for (SoftKey key : getKeys()) {
key.render();
}
}
@Override
protected ArrayList<SoftKey> getKeys() {
if (keys != null && keys.size() > 0) {
return keys;
}
ViewGroup table = view.findViewById(R.id.main_soft_keys);
int tableRowsCount = table.getChildCount();
for (int rowId = 0; rowId < tableRowsCount; rowId++) {
View row = table.getChildAt(rowId);
if (row instanceof ViewGroup) {
keys.addAll(getKeysFromContainer((ViewGroup) row));
}
}
ViewGroup statusBarContainer = view.findViewById(R.id.status_bar_container);
keys.addAll(getKeysFromContainer(statusBarContainer));
return keys;
}
protected ArrayList<View> getSeparators() {
// it's fine... it's shorter, faster and easier to read than searching with 3 nested loops
return new ArrayList<>(Arrays.asList(
view.findViewById(R.id.separator_top),
view.findViewById(R.id.separator_candidates_1),
view.findViewById(R.id.separator_candidates_2),
view.findViewById(R.id.separator_candidates_bottom),
view.findViewById(R.id.separator_1_1),
view.findViewById(R.id.separator_1_2),
view.findViewById(R.id.separator_2_1),
view.findViewById(R.id.separator_2_2),
view.findViewById(R.id.separator_3_1),
view.findViewById(R.id.separator_3_2),
view.findViewById(R.id.separator_4_1),
view.findViewById(R.id.separator_4_2)
));
}
}

View file

@ -0,0 +1,67 @@
package io.github.sspanak.tt9.ui.main;
import android.graphics.drawable.Drawable;
import android.widget.LinearLayout;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.ui.main.keys.SoftKey;
class MainLayoutSmall extends BaseMainLayout {
MainLayoutSmall(TraditionalT9 tt9) {
super(tt9, R.layout.main_small);
}
private void setSoftKeysVisibility() {
if (view != null) {
view.findViewById(R.id.main_soft_keys).setVisibility(tt9.getSettings().getShowSoftKeys() ? LinearLayout.VISIBLE : LinearLayout.GONE);
}
}
@Override
public void render() {
getView();
enableClickHandlers();
setSoftKeysVisibility();
}
@Override
final public void setDarkTheme(boolean darkEnabled) {
if (view == null) {
return;
}
// background
view.findViewById(R.id.main_soft_keys).setBackground(ContextCompat.getDrawable(
view.getContext(),
darkEnabled ? R.drawable.button_background_dark : R.drawable.button_background
));
// text
for (SoftKey key : getKeys()) {
key.setDarkTheme(darkEnabled);
}
// separators
Drawable separatorColor = ContextCompat.getDrawable(
view.getContext(),
darkEnabled ? R.drawable.button_separator_dark : R.drawable.button_separator
);
view.findViewById(R.id.main_separator_left).setBackground(separatorColor);
view.findViewById(R.id.main_separator_right).setBackground(separatorColor);
}
@Override
protected ArrayList<SoftKey> getKeys() {
if (view != null && (keys == null || keys.size() == 0)) {
keys = getKeysFromContainer(view.findViewById(R.id.main_soft_keys));
}
return keys;
}
}

View file

@ -0,0 +1,47 @@
package io.github.sspanak.tt9.ui.main;
import android.view.View;
import io.github.sspanak.tt9.ime.TraditionalT9;
public class MainView {
private final TraditionalT9 tt9;
private BaseMainLayout main;
public MainView(TraditionalT9 tt9) {
this.tt9 = tt9;
forceCreateView();
}
public boolean createView() {
if (tt9.getSettings().getShowSoftNumpad() && !(main instanceof MainLayoutNumpad)) {
main = new MainLayoutNumpad(tt9);
main.render();
return true;
} else if (!tt9.getSettings().getShowSoftNumpad() && !(main instanceof MainLayoutSmall)) {
main = new MainLayoutSmall(tt9);
main.render();
return true;
}
return false;
}
public void forceCreateView() {
main = null;
createView();
}
public View getView() {
return main.getView();
}
public void render() {
main.render();
}
public void setDarkTheme(boolean darkEnabled) {
main.setDarkTheme(darkEnabled);
}
}

View file

@ -0,0 +1,47 @@
package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
import io.github.sspanak.tt9.languages.Characters;
import io.github.sspanak.tt9.languages.Language;
public class SoftBackspaceKey extends SoftKey {
public SoftBackspaceKey(Context context) {
super(context);
}
public SoftBackspaceKey(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SoftBackspaceKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
final protected boolean handlePress() {
return handleHold();
}
@Override
final protected boolean handleHold() {
return validateTT9Handler() && tt9.onBackspace();
}
@Override
final protected boolean handleRelease() {
return false;
}
@Override
protected String getTitle() {
if (Characters.noEmojiSupported()) {
return "Del";
}
Language language = getCurrentLanguage();
return language != null && language.isRTL() ? "" : "";
}
}

View file

@ -0,0 +1,228 @@
package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.graphics.Typeface;
import android.os.Handler;
import android.os.Looper;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class SoftKey extends androidx.appcompat.widget.AppCompatButton implements View.OnTouchListener, View.OnLongClickListener {
protected TraditionalT9 tt9;
protected float complexLabelTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_TITLE_SIZE;
protected float complexLabelSubTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_SUB_TITLE_SIZE;
private boolean hold = false;
private boolean repeat = false;
private final Handler repeatHandler = new Handler(Looper.getMainLooper());
private static int lastPressedKey = -1;
public SoftKey(Context context) {
super(context);
}
public SoftKey(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SoftKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setTT9(TraditionalT9 tt9) {
this.tt9 = tt9;
}
public void setDarkTheme(boolean darkEnabled) {
int textColor = ContextCompat.getColor(
getContext(),
darkEnabled ? R.color.dark_button_text : R.color.button_text
);
setTextColor(textColor);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
getRootView().setOnTouchListener(this);
getRootView().setOnLongClickListener(this);
}
@Override
public boolean onTouch(View view, MotionEvent event) {
super.onTouchEvent(event);
int action = (event.getAction() & MotionEvent.ACTION_MASK);
if (action == MotionEvent.ACTION_DOWN) {
return handlePress();
} else if (action == MotionEvent.ACTION_UP) {
preventRepeat();
if (!repeat) {
boolean result = handleRelease();
lastPressedKey = getId();
return result;
}
repeat = false;
}
return false;
}
@Override
public boolean onLongClick(View view) {
hold = true;
// sometimes this gets called twice, so we debounce the call to the repeating function
repeatHandler.removeCallbacks(this::repeatOnLongPress);
repeatHandler.postDelayed(this::repeatOnLongPress, 1);
return true;
}
/**
* repeatOnLongPress
* Repeatedly calls "handleHold()" upon holding the respective SoftKey, to simulate physical keyboard behavior.
*/
private void repeatOnLongPress() {
if (!validateTT9Handler()) {
hold = false;
return;
}
if (hold) {
repeat = true;
handleHold();
lastPressedKey = getId();
repeatHandler.removeCallbacks(this::repeatOnLongPress);
repeatHandler.postDelayed(this::repeatOnLongPress, SettingsStore.SOFT_KEY_REPEAT_DELAY);
}
}
/**
* preventRepeat
* Prevents "handleHold()" from being called repeatedly when the SoftKey is being held.
*/
protected void preventRepeat() {
hold = false;
repeatHandler.removeCallbacks(this::repeatOnLongPress);
}
protected boolean handlePress() {
return false;
}
protected boolean handleHold() {
return false;
}
protected boolean handleRelease() {
if (!validateTT9Handler()) {
return false;
}
int keyId = getId();
boolean multiplePress = lastPressedKey == keyId;
Language language = getCurrentLanguage();
boolean isRTL = language != null && language.isRTL();
if (keyId == R.id.soft_key_add_word) return tt9.onKeyAddWord(false);
if (keyId == R.id.soft_key_filter_suggestions) return tt9.onKeyFilterSuggestions(false, multiplePress);
if (keyId == R.id.soft_key_clear_filter) return tt9.onKeyFilterClear(false);
if (keyId == R.id.soft_key_left_arrow) return tt9.onKeyScrollSuggestion(false, !isRTL);
if (keyId == R.id.soft_key_right_arrow) return tt9.onKeyScrollSuggestion(false, isRTL);
if (keyId == R.id.soft_key_language) return tt9.onKeyNextLanguage(false);
if (keyId == R.id.soft_key_ok) return tt9.onOK();
if (keyId == R.id.soft_key_settings) return tt9.onKeyShowSettings(false);
return false;
}
protected boolean validateTT9Handler() {
if (tt9 == null) {
Logger.w(getClass().getCanonicalName(), "Traditional T9 handler is not set. Ignoring key press.");
return false;
}
return true;
}
@Nullable protected Language getCurrentLanguage() {
return LanguageCollection.getLanguage(tt9.getApplicationContext(), tt9.getSettings().getInputLanguage());
}
/**
* getTitle
* Generates the name of the key, for example: "OK", "Backspace", "1", etc...
*/
protected String getTitle() {
return null;
}
/**
* getSubTitle
* Generates a String describing what the key does.
* For example: "ABC" for 2-key; "" for Backspace key, "" for Settings key, and so on.
*
* The sub title label is optional.
*/
protected String getSubTitle() {
return null;
}
/**
* render
* Sets the key label using "getTitle()" and "getSubtitle()" or if they both
* return NULL, the XML "text" attribute will be preserved.
*
* If there is only name label, it will be centered and at normal font size.
* If there is also a function label, it will be displayed below the name label and both will
* have their font size adjusted to fit inside the key.
*/
public void render() {
String title = getTitle();
String subtitle = getSubTitle();
if (title == null) {
return;
} else if (subtitle == null) {
setText(title);
return;
}
SpannableStringBuilder sb = new SpannableStringBuilder(title);
sb.append('\n');
sb.append(subtitle);
float padding = SettingsStore.SOFT_KEY_COMPLEX_LABEL_TITLE_SIZE;
if (complexLabelTitleSize == SettingsStore.SOFT_KEY_COMPLEX_LABEL_ARABIC_TITLE_SIZE) {
padding /= 10;
}
sb.setSpan(new RelativeSizeSpan(complexLabelTitleSize), 0, 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
sb.setSpan(new StyleSpan(Typeface.ITALIC), 0, 1, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
sb.setSpan(new RelativeSizeSpan(padding), 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
sb.setSpan(new RelativeSizeSpan(complexLabelSubTitleSize), 2, sb.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
setText(sb);
}
}

View file

@ -0,0 +1,30 @@
package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
public class SoftKeyInputMode extends SoftKey {
public SoftKeyInputMode(Context context) {
super(context);
}
public SoftKeyInputMode(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SoftKeyInputMode(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected boolean handleHold() {
preventRepeat();
return validateTT9Handler() && tt9.onKeyChangeKeyboard(false);
}
@Override
protected boolean handleRelease() {
return validateTT9Handler() && tt9.onKeyNextInputMode(false);
}
}

View file

@ -0,0 +1,160 @@
package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
import android.view.KeyEvent;
import java.util.ArrayList;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.helpers.Key;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class SoftNumberKey extends SoftKey {
public SoftNumberKey(Context context) {
super(context);
}
public SoftNumberKey(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SoftNumberKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected boolean handleHold() {
if (tt9 == null) {
return super.handleHold();
}
preventRepeat();
int keyCode = Key.numberToCode(getNumber(getId()));
tt9.onKeyLongPress(keyCode, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
return true;
}
@Override
protected boolean handleRelease() {
int keyCode = Key.numberToCode(getUpsideDownNumber(getId()));
if (keyCode < 0 || !validateTT9Handler()) {
return false;
}
tt9.onKeyDown(keyCode, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
tt9.onKeyUp(keyCode, new KeyEvent(KeyEvent.ACTION_UP, keyCode));
return true;
}
@Override
protected String getTitle() {
int number = getNumber(getId());
Language language = getCurrentLanguage();
if (language != null && language.isArabic() && tt9 != null && !tt9.isInputModeNumeric()) {
complexLabelTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_ARABIC_TITLE_SIZE;
return language.getKeyNumber(number);
} else {
complexLabelTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_TITLE_SIZE;
return String.valueOf(number);
}
}
@Override
protected String getSubTitle() {
if (tt9 == null) {
return null;
}
int number = getNumber(getId());
// 0
if (number == 0) {
if (tt9.isNumericModeSigned()) {
return "+/-";
} else if (tt9.isNumericModeStrict()) {
return null;
} else if (tt9.isInputModeNumeric()) {
return "+";
} else {
complexLabelSubTitleSize = 1;
return "";
}
}
// 1
if (number == 1) {
return tt9.isNumericModeStrict() ? null : ",:-)";
}
// no other special labels in 123 mode
if (tt9.isInputModeNumeric()) {
return null;
}
// 2-9
Language language = getCurrentLanguage();
if (language == null) {
Logger.d("SoftNumberKey.getLabel", "Cannot generate a label when the language is NULL.");
return "";
}
boolean isLatinBased = language.isLatinBased();
boolean isGreekBased = language.isGreek();
StringBuilder sb = new StringBuilder();
ArrayList<String> chars = language.getKeyCharacters(number, false);
for (int i = 0; i < 5 && i < chars.size(); i++) {
String currentLetter = chars.get(i);
if (
(isLatinBased && currentLetter.charAt(0) > 'z')
|| (isGreekBased && (currentLetter.charAt(0) < 'α' || currentLetter.charAt(0) > 'ω'))
) {
// As suggested by the community, there is no need to display the accented letters.
// People are used to seeing just A-Z.
continue;
}
sb.append(
tt9.getTextCase() == InputMode.CASE_UPPER ? currentLetter.toUpperCase(language.getLocale()) : currentLetter
);
}
return sb.toString();
}
private int getNumber(int keyId) {
if (keyId == R.id.soft_key_0) return 0;
if (keyId == R.id.soft_key_1) return 1;
if (keyId == R.id.soft_key_2) return 2;
if (keyId == R.id.soft_key_3) return 3;
if (keyId == R.id.soft_key_4) return 4;
if (keyId == R.id.soft_key_5) return 5;
if (keyId == R.id.soft_key_6) return 6;
if (keyId == R.id.soft_key_7) return 7;
if (keyId == R.id.soft_key_8) return 8;
if (keyId == R.id.soft_key_9) return 9;
return -1;
}
private int getUpsideDownNumber(int keyId) {
int number = getNumber(keyId);
if (tt9 != null && tt9.getSettings().getUpsideDownKeys()) {
if (number == 1) return 7;
if (number == 2) return 8;
if (number == 3) return 9;
if (number == 7) return 1;
if (number == 8) return 2;
if (number == 9) return 3;
}
return number;
}
}

View file

@ -0,0 +1,62 @@
package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.languages.Language;
public class SoftPunctuationKey extends SoftKey {
public SoftPunctuationKey(Context context) {
super(context);
}
public SoftPunctuationKey(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SoftPunctuationKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected boolean handleRelease() {
return tt9.onText(getKeyChar());
}
@Override
protected String getTitle() {
String keyChar = getKeyChar();
switch (keyChar) {
case "":
return "PUNC";
case "*":
return "";
default:
return keyChar;
}
}
private String getKeyChar() {
if (!validateTT9Handler()) {
return "";
}
int keyId = getId();
if (tt9.isInputModePhone()) {
if (keyId == R.id.soft_key_punctuation_1) return "*";
if (keyId == R.id.soft_key_punctuation_2) return "#";
} else if (tt9.isInputModeNumeric()) {
if (keyId == R.id.soft_key_punctuation_1) return ",";
if (keyId == R.id.soft_key_punctuation_2) return ".";
} else {
if (keyId == R.id.soft_key_punctuation_1) return "!";
if (keyId == R.id.soft_key_punctuation_2) {
Language language = getCurrentLanguage();
return language != null && language.isArabic() ? "؟" : "?";
}
}
return "";
}
}

View file

@ -0,0 +1,59 @@
package io.github.sspanak.tt9.ui.tray;
import android.content.Context;
import android.view.View;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.R;
public class StatusBar {
private final TextView statusView;
private String statusText;
public StatusBar(View mainView) {
statusView = mainView.findViewById(R.id.status_bar);
}
public void setText(String text) {
statusText = "[ " + text + " ]";
this.render();
}
public void setDarkTheme(boolean darkTheme) {
if (statusView == null) {
Logger.w("StatusBar.setDarkTheme", "Not changing the theme of a NULL View.");
return;
}
Context context = statusView.getContext();
int backgroundColor = ContextCompat.getColor(
context,
darkTheme ? R.color.dark_candidate_background : R.color.candidate_background
);
int color = ContextCompat.getColor(
context,
darkTheme ? R.color.dark_candidate_color : R.color.candidate_color
);
statusView.setBackgroundColor(backgroundColor);
statusView.setTextColor(color);
this.render();
}
private void render() {
if (statusText == null) {
Logger.w("StatusBar.render", "Not displaying NULL status");
return;
}
statusView.setText(statusText);
}
}

View file

@ -0,0 +1,81 @@
package io.github.sspanak.tt9.ui.tray;
import android.content.Context;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class SuggestionsAdapter extends RecyclerView.Adapter<SuggestionsAdapter.ViewHolder> {
private final SuggestionsBar suggestionsBar;
private final int layout;
private final int textViewResourceId;
private final LayoutInflater mInflater;
private final List<String> mSuggestions;
private int colorDefault;
private int colorHighlight;
private int selectedIndex = 0;
public SuggestionsAdapter(Context context, SuggestionsBar suggestionBar, int layout, int textViewResourceId, List<String> suggestions) {
this.suggestionsBar = suggestionBar;
this.layout = layout;
this.textViewResourceId = textViewResourceId;
this.mInflater = LayoutInflater.from(context);
this.mSuggestions = suggestions;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(mInflater.inflate(layout, parent, false));
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
holder.suggestionItem.setText(mSuggestions.get(position));
holder.suggestionItem.setTextColor(colorDefault);
holder.suggestionItem.setBackgroundColor(selectedIndex == position ? colorHighlight : Color.TRANSPARENT);
holder.suggestionItem.setOnClickListener(v -> suggestionsBar.onItemClick(holder.getAdapterPosition()));
}
@Override
public int getItemCount() {
return mSuggestions.size();
}
public void setSelection(int index) {
selectedIndex = index;
}
public void setColorDefault(int colorDefault) {
this.colorDefault = colorDefault;
}
public void setColorHighlight(int colorHighlight) {
this.colorHighlight = colorHighlight;
}
public class ViewHolder extends RecyclerView.ViewHolder {
final TextView suggestionItem;
ViewHolder(View itemView) {
super(itemView);
suggestionItem = itemView.findViewById(textViewResourceId);
}
}
}

View file

@ -0,0 +1,237 @@
package io.github.sspanak.tt9.ui.tray;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class SuggestionsBar {
private final List<String> suggestions = new ArrayList<>();
protected int selectedIndex = 0;
private boolean isDarkThemeEnabled = false;
private final RecyclerView mView;
private final TraditionalT9 tt9;
private SuggestionsAdapter mSuggestionsAdapter;
private final Handler alternativeScrollingHandler = new Handler();
private final int suggestionScrollingDelay;
public SuggestionsBar(TraditionalT9 tt9, View mainView) {
super();
this.tt9 = tt9;
suggestionScrollingDelay = tt9.getSettings().getSuggestionScrollingDelay();
mView = mainView.findViewById(R.id.suggestions_bar);
mView.setLayoutManager(new LinearLayoutManager(mainView.getContext(), RecyclerView.HORIZONTAL,false));
initDataAdapter(mainView.getContext());
initSeparator(mainView.getContext());
configureAnimation();
}
private void configureAnimation() {
DefaultItemAnimator animator = new DefaultItemAnimator();
animator.setMoveDuration(SettingsStore.SUGGESTIONS_SELECT_ANIMATION_DURATION);
animator.setChangeDuration(SettingsStore.SUGGESTIONS_TRANSLATE_ANIMATION_DURATION);
animator.setAddDuration(SettingsStore.SUGGESTIONS_TRANSLATE_ANIMATION_DURATION);
animator.setRemoveDuration(SettingsStore.SUGGESTIONS_TRANSLATE_ANIMATION_DURATION);
mView.setItemAnimator(animator);
}
private void initDataAdapter(Context context) {
mSuggestionsAdapter = new SuggestionsAdapter(
context,
this,
tt9.getSettings().getShowSoftNumpad() ? R.layout.suggestion_list_numpad : R.layout.suggestion_list,
R.id.suggestion_list_item,
suggestions
);
mView.setAdapter(mSuggestionsAdapter);
setDarkTheme(tt9.getSettings().getDarkTheme());
}
private void initSeparator(Context context) {
// Extra XML is required instead of a ColorDrawable object, because setting the highlight color
// erases the borders defined using the ColorDrawable.
Drawable separatorDrawable = ContextCompat.getDrawable(context, R.drawable.suggestion_separator);
if (separatorDrawable == null) {
return;
}
DividerItemDecoration separator = new DividerItemDecoration(mView.getContext(), RecyclerView.HORIZONTAL);
separator.setDrawable(separatorDrawable);
mView.addItemDecoration(separator);
}
public boolean isEmpty() {
return suggestions.size() == 0;
}
public int getCurrentIndex() {
return selectedIndex;
}
public String getCurrentSuggestion() {
return getSuggestion(selectedIndex);
}
@NonNull
public String getSuggestion(int id) {
if (id < 0 || id >= suggestions.size()) {
return "";
}
return suggestions.get(id).equals("") ? "\n" : suggestions.get(id);
}
@SuppressLint("NotifyDataSetChanged")
public void setSuggestions(List<String> newSuggestions, int initialSel) {
ecoSetBackground(newSuggestions);
suggestions.clear();
selectedIndex = 0;
if (newSuggestions != null) {
for (String suggestion : newSuggestions) {
// make the new line better readable
suggestions.add(suggestion.equals("\n") ? "" : suggestion);
}
selectedIndex = Math.max(initialSel, 0);
}
mSuggestionsAdapter.setSelection(selectedIndex);
mSuggestionsAdapter.notifyDataSetChanged();
mView.scrollToPosition(selectedIndex);
}
public void scrollToSuggestion(int increment) {
if (suggestions.size() <= 1) {
return;
}
int oldIndex = selectedIndex;
selectedIndex = selectedIndex + increment;
if (selectedIndex == suggestions.size()) {
selectedIndex = 0;
} else if (selectedIndex < 0) {
selectedIndex = suggestions.size() - 1;
}
mSuggestionsAdapter.setSelection(selectedIndex);
mSuggestionsAdapter.notifyItemChanged(oldIndex);
mSuggestionsAdapter.notifyItemChanged(selectedIndex);
if (suggestionScrollingDelay > 0) {
alternativeScrollingHandler.postDelayed(() -> mView.scrollToPosition(selectedIndex), suggestionScrollingDelay);
} else {
mView.scrollToPosition(selectedIndex);
}
}
/**
* setDarkTheme
* Changes the suggestion colors according to the theme.
*
* We need to do this manually, instead of relying on the Context to resolve the appropriate colors,
* because this View is part of the main service View. And service Views are always locked to the
* system context and theme.
*
* More info:
* <a href="https://stackoverflow.com/questions/72382886/system-applies-night-mode-to-views-added-in-service-type-application-overlay">...</a>
*/
public void setDarkTheme(boolean darkEnabled) {
isDarkThemeEnabled = darkEnabled;
Context context = mView.getContext();
int defaultColor = darkEnabled ? R.color.dark_candidate_color : R.color.candidate_color;
int highlightColor = darkEnabled ? R.color.dark_candidate_selected : R.color.candidate_selected;
mSuggestionsAdapter.setColorDefault(ContextCompat.getColor(context, defaultColor));
mSuggestionsAdapter.setColorHighlight(ContextCompat.getColor(context, highlightColor));
setBackground(suggestions);
}
/**
* setBackground
* Makes the background transparent, when there are no suggestions and theme-colored,
* when there are suggestions.
*/
private void setBackground(List<String> newSuggestions) {
int newSuggestionsSize = newSuggestions != null ? newSuggestions.size() : 0;
if (newSuggestionsSize == 0) {
mView.setBackgroundColor(Color.TRANSPARENT);
return;
}
int color = ContextCompat.getColor(
mView.getContext(),
isDarkThemeEnabled ? R.color.dark_candidate_background : R.color.candidate_background
);
mView.setBackgroundColor(color);
}
/**
* ecoSetBackground
* A performance-optimized version of "setBackground().
* Determines if the suggestions have changed and only then it changes the background.
*/
private void ecoSetBackground(List<String> newSuggestions) {
int newSuggestionsSize = newSuggestions != null ? newSuggestions.size() : 0;
if (
(newSuggestionsSize == 0 && suggestions.size() == 0)
|| (newSuggestionsSize > 0 && suggestions.size() > 0)
) {
return;
}
setBackground(newSuggestions);
}
/**
* onItemClick
* Passes through suggestion selected using the touchscreen.
*/
public void onItemClick(int position) {
selectedIndex = position;
tt9.onOK();
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.2266667"
android:scaleY="1.2266667"
android:translateX="-2.72"
android:translateY="-2.72">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#989A98"
android:endColor="#E7EBE7"
android:angle="90" />
</shape>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- Gradient Bg for button -->
<gradient
android:startColor="#181C18"
android:endColor="#6B6D6B"
android:angle="90" />
</shape>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#686C68"
android:endColor="#B8BCB8"
android:angle="90" />
</shape>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#2E322E"
android:endColor="#878B87"
android:angle="90" />
</shape>

Some files were not shown because too many files have changed in this diff Show more