upgraded Gradle 8.0.2 -> 8.2.2
This commit is contained in:
parent
041690f8bd
commit
140b8ced08
192 changed files with 162 additions and 187 deletions
102
app/build.gradle
Normal file
102
app/build.gradle
Normal 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
19
app/constants.gradle
Normal 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
|
||||
6
app/dictionary-tools.gradle
Normal file
6
app/dictionary-tools.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
16
app/languages/definitions/Arabic.yml
Normal file
16
app/languages/definitions/Arabic.yml
Normal 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
|
||||
15
app/languages/definitions/BrazilianPortuguese.yml
Normal file
15
app/languages/definitions/BrazilianPortuguese.yml
Normal 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
|
||||
14
app/languages/definitions/Bulgarian.yml
Normal file
14
app/languages/definitions/Bulgarian.yml
Normal 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
|
||||
14
app/languages/definitions/Danish.yml
Normal file
14
app/languages/definitions/Danish.yml
Normal 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
|
||||
14
app/languages/definitions/Dutch.yml
Normal file
14
app/languages/definitions/Dutch.yml
Normal 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
|
||||
14
app/languages/definitions/English.yml
Normal file
14
app/languages/definitions/English.yml
Normal 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
|
||||
14
app/languages/definitions/Finnish.yml
Normal file
14
app/languages/definitions/Finnish.yml
Normal 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
|
||||
14
app/languages/definitions/French.yml
Normal file
14
app/languages/definitions/French.yml
Normal 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
|
||||
14
app/languages/definitions/German.yml
Normal file
14
app/languages/definitions/German.yml
Normal 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
|
||||
14
app/languages/definitions/Greek.yml
Normal file
14
app/languages/definitions/Greek.yml
Normal 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
|
||||
16
app/languages/definitions/Hebrew.yml
Normal file
16
app/languages/definitions/Hebrew.yml
Normal 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
|
||||
15
app/languages/definitions/Indonesian.yml
Normal file
15
app/languages/definitions/Indonesian.yml
Normal 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
|
||||
14
app/languages/definitions/Italian.yml
Normal file
14
app/languages/definitions/Italian.yml
Normal 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
|
||||
14
app/languages/definitions/Norwegian.yml
Normal file
14
app/languages/definitions/Norwegian.yml
Normal 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
|
||||
14
app/languages/definitions/Polish.yml
Normal file
14
app/languages/definitions/Polish.yml
Normal 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
|
||||
14
app/languages/definitions/Romanian.yml
Normal file
14
app/languages/definitions/Romanian.yml
Normal 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
|
||||
14
app/languages/definitions/Russian.yml
Normal file
14
app/languages/definitions/Russian.yml
Normal 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
|
||||
14
app/languages/definitions/Spanish.yml
Normal file
14
app/languages/definitions/Spanish.yml
Normal 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
|
||||
14
app/languages/definitions/Swedish.yml
Normal file
14
app/languages/definitions/Swedish.yml
Normal 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
|
||||
14
app/languages/definitions/Ukrainian.yml
Normal file
14
app/languages/definitions/Ukrainian.yml
Normal 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
|
||||
16
app/languages/definitions/Yiddish.yml
Normal file
16
app/languages/definitions/Yiddish.yml
Normal 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
|
||||
1272407
app/languages/dictionaries/ar-utf8.csv
Normal file
1272407
app/languages/dictionaries/ar-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
1133296
app/languages/dictionaries/bg-utf8.csv
Normal file
1133296
app/languages/dictionaries/bg-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
416432
app/languages/dictionaries/da-utf8.csv
Normal file
416432
app/languages/dictionaries/da-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
224382
app/languages/dictionaries/de-utf8.csv
Normal file
224382
app/languages/dictionaries/de-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
173869
app/languages/dictionaries/en-utf8.csv
Normal file
173869
app/languages/dictionaries/en-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
1035303
app/languages/dictionaries/es-utf8.csv
Normal file
1035303
app/languages/dictionaries/es-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
223079
app/languages/dictionaries/fi-utf8.csv
Normal file
223079
app/languages/dictionaries/fi-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
333584
app/languages/dictionaries/fr-utf8.csv
Normal file
333584
app/languages/dictionaries/fr-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
639917
app/languages/dictionaries/gr-utf8.csv
Normal file
639917
app/languages/dictionaries/gr-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
469510
app/languages/dictionaries/he-utf8.csv
Normal file
469510
app/languages/dictionaries/he-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
77286
app/languages/dictionaries/id-utf8.csv
Normal file
77286
app/languages/dictionaries/id-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
652273
app/languages/dictionaries/it-utf8.csv
Normal file
652273
app/languages/dictionaries/it-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
44794
app/languages/dictionaries/ji-utf8.csv
Normal file
44794
app/languages/dictionaries/ji-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
171233
app/languages/dictionaries/nb-utf8.csv
Normal file
171233
app/languages/dictionaries/nb-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
332903
app/languages/dictionaries/nl-utf8.csv
Normal file
332903
app/languages/dictionaries/nl-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
513062
app/languages/dictionaries/pl-utf8.csv
Normal file
513062
app/languages/dictionaries/pl-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
261919
app/languages/dictionaries/pt-BR-utf8.csv
Normal file
261919
app/languages/dictionaries/pt-BR-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
1163029
app/languages/dictionaries/ro-utf8.csv
Normal file
1163029
app/languages/dictionaries/ro-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
1428882
app/languages/dictionaries/ru-utf8.csv
Normal file
1428882
app/languages/dictionaries/ru-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
196810
app/languages/dictionaries/sv-utf8.csv
Normal file
196810
app/languages/dictionaries/sv-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
1304594
app/languages/dictionaries/uk-utf8.csv
Normal file
1304594
app/languages/dictionaries/uk-utf8.csv
Normal file
File diff suppressed because it is too large
Load diff
20
app/proguard-rules.pro
vendored
Normal file
20
app/proguard-rules.pro
vendored
Normal 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 *;
|
||||
#}
|
||||
38
app/src/main/AndroidManifest.xml
Normal file
38
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
10
app/src/main/java/io/github/sspanak/tt9/ConsumerCompat.java
Normal file
10
app/src/main/java/io/github/sspanak/tt9/ConsumerCompat.java
Normal 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;}
|
||||
}
|
||||
46
app/src/main/java/io/github/sspanak/tt9/Logger.java
Normal file
46
app/src/main/java/io/github/sspanak/tt9/Logger.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/io/github/sspanak/tt9/TextTools.java
Normal file
54
app/src/main/java/io/github/sspanak/tt9/TextTools.java
Normal 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));
|
||||
}
|
||||
}
|
||||
370
app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java
Normal file
370
app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/io/github/sspanak/tt9/db/LegacyDb.java
Normal file
39
app/src/main/java/io/github/sspanak/tt9/db/LegacyDb.java
Normal 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) {}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
259
app/src/main/java/io/github/sspanak/tt9/db/WordStore.java
Normal file
259
app/src/main/java/io/github/sspanak/tt9/db/WordStore.java
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.github.sspanak.tt9.db.exceptions;
|
||||
|
||||
public class DictionaryImportAbortedException extends Exception{
|
||||
public DictionaryImportAbortedException() {
|
||||
super("Dictionary import stopped by request.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.github.sspanak.tt9.db.exceptions;
|
||||
|
||||
public class DictionaryImportAlreadyRunningException extends Exception{
|
||||
public DictionaryImportAlreadyRunningException() {
|
||||
super("Dictionary import is already running.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
224
app/src/main/java/io/github/sspanak/tt9/db/sqlite/ReadOps.java
Normal file
224
app/src/main/java/io/github/sspanak/tt9/db/sqlite/ReadOps.java
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/io/github/sspanak/tt9/db/sqlite/Tables.java
Normal file
109
app/src/main/java/io/github/sspanak/tt9/db/sqlite/Tables.java
Normal 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 " +
|
||||
")";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
304
app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java
Normal file
304
app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java
Normal 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();
|
||||
}
|
||||
798
app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java
Normal file
798
app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
97
app/src/main/java/io/github/sspanak/tt9/ime/helpers/Key.java
Normal file
97
app/src/main/java/io/github/sspanak/tt9/ime/helpers/Key.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
145
app/src/main/java/io/github/sspanak/tt9/ime/modes/InputMode.java
Normal file
145
app/src/main/java/io/github/sspanak/tt9/ime/modes/InputMode.java
Normal 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; }
|
||||
}
|
||||
114
app/src/main/java/io/github/sspanak/tt9/ime/modes/Mode123.java
Normal file
114
app/src/main/java/io/github/sspanak/tt9/ime/modes/Mode123.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package io.github.sspanak.tt9.languages;
|
||||
|
||||
public class InvalidLanguageException extends Exception {
|
||||
public InvalidLanguageException() { super("Invalid Language"); }
|
||||
}
|
||||
304
app/src/main/java/io/github/sspanak/tt9/languages/Language.java
Normal file
304
app/src/main/java/io/github/sspanak/tt9/languages/Language.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue