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

102
app/build.gradle Normal file
View file

@ -0,0 +1,102 @@
plugins {
id 'com.android.application'
id "at.zierler.yamlvalidator" version "1.5.0"
}
apply from: 'constants.gradle'
apply from: 'dictionary-tools.gradle'
apply from: 'validate-languages.gradle'
apply from: 'version-tools.gradle'
tasks.register('validateLanguages') {
mustRunAfter(validateYaml)
inputs.dir fileTree(dir: LANGUAGES_INPUT_DIR)
outputs.file "${project.buildDir}/lang.validation.txt"
doLast {
validateLanguageFiles(DEFINITIONS_INPUT_DIR, DICTIONARIES_INPUT_DIR, outputs.files.singleFile)
}
}
tasks.register('copyLanguages', Copy) {
from LANGUAGES_INPUT_DIR
include '**/*.csv'
include '**/*.txt'
include '**/*.yml'
into LANGUAGES_OUTPUT_DIR
}
tasks.register('calculateDictionarySizes') {
inputs.dir fileTree(dir: DICTIONARIES_INPUT_DIR)
outputs.dir DICTIONARIES_OUTPUT_DIR
doLast {
getDictionarySizes(DICTIONARIES_INPUT_DIR, DICTIONARIES_OUTPUT_DIR)
}
}
clean {
delete LANGUAGES_OUTPUT_DIR
}
// using the exported Closures directly causes weird values, hence the extra wrappers here
def getVerCode = { -> return getVersionCode() }
def getVerName = { -> return getVersionName() }
def getVersionString = { flavor -> return flavor == 'debug' ? getDebugVersion() : getReleaseVersion() }
android {
namespace 'io.github.sspanak.tt9'
compileSdk 34
defaultConfig {
applicationId "io.github.sspanak.tt9"
minSdk 19
//noinspection ExpiredTargetSdkVersion
targetSdk 30
versionCode getVerCode()
versionName getVerName()
}
buildFeatures {
buildConfig true
}
buildTypes {
debug {
buildConfigField 'String', 'VERSION_FULL', "\"${getVersionString('debug')}\""
}
release {
buildConfigField 'String', 'VERSION_FULL', "\"${getVersionString('release')}\""
debuggable false
jniDebuggable false
// minifyEnabled true
// shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
applicationVariants.configureEach { variant ->
tasks.named("generate${variant.name.capitalize()}Assets")?.configure {
dependsOn(validateLanguages, copyLanguages, calculateDictionarySizes)
}
// generateDebugLintReportModel
["lintAnalyzeDebug", "generateDebugLintReportModel", "lintVitalAnalyzeRelease", "generateReleaseLintVitalReportModel"].each { taskName ->
tasks.named(taskName)?.configure {
dependsOn(validateLanguages, copyLanguages, calculateDictionarySizes)
}
}
}
}
dependencies {
implementation 'androidx.preference:preference:1.2.1'
}

19
app/constants.gradle Normal file
View file

@ -0,0 +1,19 @@
ext.LANGUAGES_DIR_NAME = 'languages'
ext.DEFINITIONS_DIR_NAME = 'definitions'
ext.DICTIONARIES_DIR_NAME = 'dictionaries'
ext.DICTIONARY_SIZES_DIR_NAME = 'dictionary-sizes'
def ROOT_DIR = "${project.rootDir}/app"
def ASSETS_DIR = "${ROOT_DIR}/src/main/assets"
ext.LANGUAGES_INPUT_DIR = "${ROOT_DIR}/${LANGUAGES_DIR_NAME}"
ext.DEFINITIONS_INPUT_DIR = "${LANGUAGES_INPUT_DIR}/${DEFINITIONS_DIR_NAME}"
ext.DICTIONARIES_INPUT_DIR = "${LANGUAGES_INPUT_DIR}/${DICTIONARIES_DIR_NAME}"
ext.LANGUAGES_OUTPUT_DIR = "${ASSETS_DIR}/${LANGUAGES_DIR_NAME}"
ext.DEFINITIONS_OUTPUT_DIR = "${LANGUAGES_OUTPUT_DIR}/${DEFINITIONS_DIR_NAME}"
ext.DICTIONARIES_OUTPUT_DIR = "${LANGUAGES_OUTPUT_DIR}/${DICTIONARIES_DIR_NAME}"
ext.CSV_DELIMITER = ' ' // TAB
ext.MAX_WORD_FREQUENCY = 255
ext.MAX_ERRORS = 50

View file

@ -0,0 +1,6 @@
ext.getDictionarySizes = { dictionariesDir, sizesDir ->
fileTree(dir: dictionariesDir).forEach {dictionary ->
def dictionarySize = dictionary.exists() ? dictionary.text.split("\n").length : 0
new File(sizesDir, "${dictionary.getName()}.size").text = dictionarySize
}
}

View file

@ -0,0 +1,16 @@
---
locale: ar-JO
dictionaryFile: ar-utf8.csv
abcString: أﺏﺕ
hasUpperCase: no
layout:
- [SPECIAL] # 0
- [PUNCTUATION_AR] # 1
- [ب,ت,ة,ث] # 2
- [ا,أ,إ,آ,ى,ؤ,ئ,ء] # 3
- [س,ش,ص,ض] # 4
- [د,ذ,ر,ز] # 5
- [ج,ح,خ] # 6
- [ن,ه,و,ي] # 7
- [ف,ق,ك,ل,م] # 8
- [ط,ظ,ع,غ] # 9

View file

@ -0,0 +1,15 @@
---
locale: pt-BR
dictionaryFile: pt-BR-utf8.csv
name: Português brasileiro
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c, ç, á, â, ã, à] # 2
- [d, e, f, é, ê, è] # 3
- [g, h, i, í] # 4
- [j, k, l] # 5
- [m, n, o, ó, ô, õ] # 6
- [p, q, r, s] # 7
- [t, u, v, ú] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: bg-BG
dictionaryFile: bg-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_DE] # 1
- [а, б, в, г] # 2
- [д, е, ж, з] # 3
- [и, й, к, л, ѝ] # 4
- [м, н, о, п] # 5
- [р, с, т, у] # 6
- [ф, х, ц, ч] # 7
- [ш, щ, ъ, ь] # 8
- [ю, я] # 9

View file

@ -0,0 +1,14 @@
---
locale: da-DK
dictionaryFile: da-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [a, b, c, æ, å] # 2
- [d, e, f, é] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o, ø] # 6
- [p, q, r, s] # 7
- [t, u, v] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: nl-NL
dictionaryFile: nl-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c, à, ä, ç] # 2
- [d, e, f, é, è, ê, ë] # 3
- [g, h, i, î, ï] # 4
- [j, k, l] # 5
- [m, n, o, ö] # 6
- [p, q, r, s] # 7
- [t, u, v, û, ü] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: en
dictionaryFile: en-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c] # 2
- [d, e, f] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o] # 6
- [p, q, r, s] # 7
- [t, u, v] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: fi-FI
dictionaryFile: fi-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c, ä, å] # 2
- [d, e, f] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o, ö] # 6
- [p, q, r, s] # 7
- [t, u, v] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: fr
dictionaryFile: fr-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [a, b, c, à, â, æ, ç] # 2
- [d, e, f, é, è, ê, ë] # 3
- [g, h, i, î, ï] # 4
- [j, k, l] # 5
- [m, n, o, ô, œ] # 6
- [p, q, r, s] # 7
- [t, u, v, ù, û, ü] # 8
- [w, x, y, z, ÿ] # 9

View file

@ -0,0 +1,14 @@
---
locale: de
dictionaryFile: de-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_DE] # 1
- [a, b, c, ä] # 2
- [d, e, f] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o, ö] # 6
- [p, q, r, s, ß] # 7
- [t, u, v, ü] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: el-GR
dictionaryFile: gr-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [α, β, γ, ά] # 2
- [δ, ε, ζ, έ] # 3
- [η, θ, ι, ή, ί, ϊ, ΐ] # 4
- [κ, λ, μ] # 5
- [ν, ξ, ο, ό] # 6
- [π, ρ, σ, ς] # 7
- [τ, υ, φ, ύ, ϋ, ΰ] # 8
- [χ, ψ, ω, ώ] # 9

View file

@ -0,0 +1,16 @@
---
locale: iw-IL
dictionaryFile: he-utf8.csv
abcString: אבג
hasUpperCase: no
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [ד, ה, ו] # 2
- [א, ב, ג] # 3
- [מ, ם, נ, ן] # 4
- [י, כ, ך, ל] # 5
- [ז, ח, ט] # 6
- [ר, ש, ת] # 7
- [צ, ץ, ק] # 8
- [ס, ע, פ, ף] # 9

View file

@ -0,0 +1,15 @@
---
locale: in-ID
dictionaryFile: id-utf8.csv
name: Bahasa Indonesia
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c] # 2
- [d, e, f] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o] # 6
- [p, q, r, s] # 7
- [t, u, v] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: it
dictionaryFile: it-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c, à] # 2
- [d, e, f, é, è] # 3
- [g, h, i, ì, í, î] # 4
- [j, k, l] # 5
- [m, n, o, ò, ó] # 6
- [p, q, r, s] # 7
- [t, u, v, ù, ú] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: nb-NO
dictionaryFile: nb-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [a, b, c, æ, å] # 2
- [d, e, f, é, è] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o, ø, ó, ò, ô] # 6
- [p, q, r, s] # 7
- [t, u, v, ü] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: pl-PL
dictionaryFile: pl-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_DE] # 1
- [a, b, c, ą, ć] # 2
- [d, e, f, ę] # 3
- [g, h, i] # 4
- [j, k, l, ł] # 5
- [m, n, o, ó, ń] # 6
- [p, q, r, s, ś] # 7
- [t, u, v] # 8
- [w, x, y, z, ź, ż] # 9

View file

@ -0,0 +1,14 @@
---
locale: ro-RO
dictionaryFile: ro-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [a, b, c, ă, â, á] # 2
- [d, e, f, é, è] # 3
- [g, h, i, î] # 4
- [j, k, l] # 5
- [m, n, o] # 6
- [p, q, r, s, ș] # 7
- [t, u, v, ț] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: ru-RU
dictionaryFile: ru-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [а, б, в, г] # 2
- [д, е, ё, ж, з] # 3
- [и, й, к, л] # 4
- [м, н, о, п] # 5
- [р, с, т, у] # 6
- [ф, х, ц, ч] # 7
- [ш, щ, ъ, ы] # 8
- [ь, э, ю, я] # 9

View file

@ -0,0 +1,14 @@
---
locale: es-ES
dictionaryFile: es-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR, ¡, ¿] # 1
- [a, b, c, á] # 2
- [d, e, f, é] # 3
- [g, h, i, í] # 4
- [j, k, l] # 5
- [m, n, ñ, o, ó] # 6
- [p, q, r, s] # 7
- [t, u, v, ú, ü] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: sv-SE
dictionaryFile: sv-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [a, b, c, å, ä] # 2
- [d, e, f, é] # 3
- [g, h, i] # 4
- [j, k, l] # 5
- [m, n, o, ö] # 6
- [p, q, r, s] # 7
- [t, u, v] # 8
- [w, x, y, z] # 9

View file

@ -0,0 +1,14 @@
---
locale: uk-UA
dictionaryFile: uk-utf8.csv
layout:
- [SPECIAL] # 0
- [PUNCTUATION_FR] # 1
- [а, б, в, г, ґ] # 2
- [д, е, є, ж, з] # 3
- [и, і, ї, й, к, л] # 4
- [м, н, о, п] # 5
- [р, с, т, у] # 6
- [ф, х, ц, ч] # 7
- [ш, щ] # 8
- [ь, ю, я] # 9

View file

@ -0,0 +1,16 @@
---
locale: ji-JI
dictionaryFile: ji-utf8.csv
abcString: אבג
hasUpperCase: no
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [ד, ה, ו] # 2
- [א, ב, ג] # 3
- [מ, ם, נ, ן] # 4
- [י, כ, ך, ל] # 5
- [ז, ח, ט] # 6
- [ר, ש, ת] # 7
- [צ, ץ, ק] # 8
- [ס, ע, פ, ף] # 9

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

20
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,20 @@
# To enable ProGuard in your project, edit project.properties
# to define the proguard.config property as described in that file.
#
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in ${sdk.dir}/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the ProGuard
# include property in project.properties.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

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() : "");
}
}

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