diff --git a/AGENTS.md b/AGENTS.md index 516b7e7b4..a0d055f10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,13 +72,14 @@ $env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot" - Docs: `docs/FEATURES.md`, `docs/TWO_THUMB_TYPING_INTERNALS.md`, `docs/IMPROVEMENT_PLAN.md`, `layouts.md`, `CONTRIBUTING.md` ## Runtime/Tooling Preferences -- **Android:** `compileSdk`/`targetSdk` 35, `minSdk` 21; Java/Kotlin JVM target 17. Build with JDK 17 or 21. +- **Android:** `compileSdk` 36, `targetSdk` 35, `minSdk` 21; Java/Kotlin JVM target 17. Build with JDK 17 or 21. - **Toolchain:** Gradle 8.13 (wrapper), Kotlin 2.2.21, Compose BOM 2025.11.01 (`material3`, `navigation-compose`). Package management is **Gradle only** (no npm/bun/yarn). - **Native:** ABIs `armeabi-v7a`, `arm64-v8a`; built via `ndkBuild` (`app/src/main/jni/Android.mk`). -- **Flavors** (dimension `privacy`, appId base `com.leanbitlab.leantype`): +- **Flavors** (dimension `privacy`, appId base `com.asafmah.leantypedual`): - `standard` — cloud AI (Gemini, `generativeai`), has `INTERNET`. - - `offline` — on-device ONNX (`onnxruntime-android`), **no** `INTERNET`; appId `+.offline`. - - `offlinelite` — no AI, smallest; appId `+.offlinelite`. + - `standardfull` — cloud AI plus handwriting, has `INTERNET`. + - `offline` — on-device llama.cpp / GGUF, **no** `INTERNET`; appId `+.offline`, minSdk 26. + - `offlinelite` — no AI, smallest; **no** `INTERNET`; appId `+.offlinelite`. - **Build types:** `debug` (no minify, `+.debug`), `release` (minify + shrink + signed via `keystore.properties`), `runTests` (CI variant that skips known-failing tests), `debugNoMinify` (fast IDE builds). - **CI:** `.github/workflows/build-test-auto.yml` runs `compileOfflineRunTestsKotlin` on PRs touching `app/src/main/java**`; `build-debug-apk.yml` runs `assembleDebug` on manual dispatch. Release chores live in `tools/release.py`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f708f0a..c50a22334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118) ### Upstream -- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4) — adds direct switching to a configured IME, persistent custom-layout state, multi-word suggestion controls, and the v3.9.5 release notes. Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. +- Merged **LeanBitLab/LeanType v3.9.9** (pinned at `adb7c3755`, including v3.9.6–v3.9.8) — adds system-emoji and app-language settings, multi-row number rows, toolbar swipe-to-dismiss, translation and spellcheck improvements, asynchronous fallback-gesture indexing, dictionary upgrade preservation, and native dictionary crash/ANR fixes. Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved. (#121) ## [3.10.0] - 2026-06-20 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7f6199d8..71f3f648d 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -226,6 +226,7 @@ dependencies { implementation("androidx.recyclerview:recyclerview:1.4.0") implementation("androidx.autofill:autofill:1.3.0") implementation("androidx.viewpager2:viewpager2:1.1.0") + implementation("androidx.emoji2:emoji2:1.4.0") // kotlin implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 59b51eedb..e05f48886 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,17 +137,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:name="androidx.work.WorkManagerInitializer" android:value="androidx.startup" tools:node="remove" /> - - - - diff --git a/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt b/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt index 8dc5421f7..edb9f9179 100644 --- a/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt +++ b/app/src/main/java/helium314/keyboard/compat/ImeCompat.kt @@ -14,7 +14,7 @@ object ImeCompat { fun InputMethodService.switchInputMethod(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return switchToNextInputMethod(false) val window = window.window ?: return false - val token = window.attributes.token + val token = window.attributes.token ?: return false return RichInputMethodManager.getInstance().inputMethodManager.switchToNextInputMethod(token, false) } @@ -26,13 +26,40 @@ object ImeCompat { return RichInputMethodManager.getInstance().inputMethodManager.shouldOfferSwitchingToNextInputMethod(token) } - fun InputMethodService.switchInputMethodAndSubtype(imi: InputMethodInfo, subtype: InputMethodSubtype) { + fun InputMethodService.switchInputMethodCompat(imiId: String) { + val window = window.window + val token = window?.attributes?.token + if (token != null) { + try { + RichInputMethodManager.getInstance().inputMethodManager.setInputMethod(token, imiId) + return + } catch (e: Throwable) { + // fallback + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + switchInputMethod(imiId) + } + } + + fun InputMethodService.switchInputMethodAndSubtypeCompat(imi: InputMethodInfo, subtype: InputMethodSubtype) { + val window = window.window + val token = window?.attributes?.token + if (token != null) { + try { + RichInputMethodManager.getInstance().inputMethodManager.setInputMethodAndSubtype(token, imi.id, subtype) + return + } catch (e: Throwable) { + // fallback + } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { switchInputMethod(imi.id, subtype) } else { - val window = window.window ?: return - val token = window.attributes.token - RichInputMethodManager.getInstance().inputMethodManager.setInputMethodAndSubtype(token, imi.id, subtype) + val fallbackToken = token ?: return + try { + RichInputMethodManager.getInstance().inputMethodManager.setInputMethod(fallbackToken, imi.id) + } catch (e: Throwable) {} } } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt index 69d7c3e63..4a4d17757 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt @@ -101,7 +101,8 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co params.mBaseWidth = params.mOccupiedWidth - params.mLeftPadding - params.mRightPadding } - val numberRow = getNumberRow() + val numberRows = getNumberRows() + val numberRow = numberRows.first() addNumberRowOrPopupKeys(baseKeys, numberRow) if (params.mId.isAlphabetKeyboard) addSymbolPopupKeys(baseKeys) @@ -109,7 +110,9 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co || (!params.mId.isAlphabetKeyboard && params.mId.mNumberRowInSymbols && !params.mId.mCompactNumberRowInSymbols))) { val newLabelFlags = defaultLabelFlags or if (Settings.getValues().mShowNumberRowHints) 0 else Key.LABEL_FLAGS_DISABLE_HINT_LABEL - baseKeys.add(0, numberRow.mapTo(mutableListOf()) { it.copy(newLabelFlags = newLabelFlags) }) + numberRows.forEachIndexed { rowIndex, row -> + baseKeys.add(rowIndex, row.mapTo(mutableListOf()) { it.copy(newLabelFlags = newLabelFlags) }) + } } if (!params.mAllowRedundantPopupKeys) params.baseKeys = baseKeys.flatMap { row -> row.map { it.toKeyParams(params) } } @@ -317,38 +320,49 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co } } - private fun getNumberRow(): MutableList { - val row = LayoutParser.parseLayout(LayoutType.NUMBER_ROW, params, context).first() + private fun getNumberRows(): MutableList> { + val rows = LayoutParser.parseLayout(LayoutType.NUMBER_ROW, params, context) + if (rows.isEmpty()) { + rows.add(mutableListOf()) + } val localizedNumbers = params.mLocaleKeyboardInfos.localizedNumberKeys if (localizedNumbers?.size == 10) { - if (Settings.getValues().mLocalizedNumberRow) { - // replace 0-9 with localized numbers, and move latin number into popup - for (i in row.indices) { - val key = row[i] - val number = key.label.toIntOrNull() ?: continue - when (number) { - 0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup)) - in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup)) + for (row in rows) { + if (Settings.getValues().mLocalizedNumberRow) { + // replace 0-9 with localized numbers, and move latin number into popup + for (i in row.indices) { + val key = row[i] + val number = key.label.toIntOrNull() ?: continue + when (number) { + 0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup)) + in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup)) + } } - } - } else { - // add localized numbers to popups on 0-9 - for (i in row.indices) { - val key = row[i] - val number = key.label.toIntOrNull() ?: continue - when (number) { - 0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup)) - in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup)) + } else { + // add localized numbers to popups on 0-9 + for (i in row.indices) { + val key = row[i] + val number = key.label.toIntOrNull() ?: continue + when (number) { + 0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup)) + in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup)) + } } } } } if (params.mId.mElementId == KeyboardId.ELEMENT_SYMBOLS_SHIFTED) { - for (i in row.indices) { - row[i] = shiftKeyData(row[i]) + for (row in rows) { + for (i in row.indices) { + row[i] = shiftKeyData(row[i]) + } } } - return row + return rows + } + + private fun getNumberRow(): MutableList { + return getNumberRows().first() } private fun shiftKeyData(key: KeyData): KeyData { diff --git a/app/src/main/java/helium314/keyboard/latin/App.kt b/app/src/main/java/helium314/keyboard/latin/App.kt index 86ad69460..e6270fbd0 100644 --- a/app/src/main/java/helium314/keyboard/latin/App.kt +++ b/app/src/main/java/helium314/keyboard/latin/App.kt @@ -2,6 +2,8 @@ package helium314.keyboard.latin import android.app.Application +import androidx.emoji2.text.EmojiCompat +import androidx.emoji2.text.DefaultEmojiCompatConfig import androidx.work.Configuration import helium314.keyboard.keyboard.emoji.SupportedEmojis import helium314.keyboard.latin.define.DebugFlags @@ -23,6 +25,13 @@ class App : Application(), Configuration.Provider { super.onCreate() DebugFlags.init(this) Settings.init(this) + val useSystemEmoji = Settings.getInstance().useSystemEmoji() + if (!useSystemEmoji) { + val config = DefaultEmojiCompatConfig.create(this) + if (config != null) { + EmojiCompat.init(config) + } + } SubtypeSettings.init(this) RichInputMethodManager.init(this) diff --git a/app/src/main/java/helium314/keyboard/latin/AppUpgrade.kt b/app/src/main/java/helium314/keyboard/latin/AppUpgrade.kt index a41d7a6ce..816ae69ec 100644 --- a/app/src/main/java/helium314/keyboard/latin/AppUpgrade.kt +++ b/app/src/main/java/helium314/keyboard/latin/AppUpgrade.kt @@ -11,6 +11,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode.check import helium314.keyboard.latin.common.ColorType import helium314.keyboard.latin.common.Constants.Separators import helium314.keyboard.latin.common.Constants.Subtype.ExtraValue +import helium314.keyboard.latin.common.LocaleUtils import helium314.keyboard.latin.common.LocaleUtils.constructLocale import helium314.keyboard.latin.common.encodeBase36 import helium314.keyboard.latin.database.ClipboardDao @@ -57,10 +58,53 @@ object AppUpgrade { if (oldVersion == BuildConfig.VERSION_CODE) return // clear extracted dictionaries, in case updated version contains newer ones - DictionaryInfoUtils.getCacheDirectories(context).forEach { - for (file in it.listFiles()!!) { - if (!file.name.endsWith(USER_DICTIONARY_SUFFIX)) + val assetsList = DictionaryInfoUtils.getAssetsDictionaryList(context) + DictionaryInfoUtils.getCacheDirectories(context).forEach { dir -> + val locale = DictionaryInfoUtils.getWordListIdFromFileName(dir.name).constructLocale() + for (file in dir.listFiles().orEmpty()) { + if (file.name.endsWith(USER_DICTIONARY_SUFFIX)) continue + + val type = file.name.substringBefore("_").substringBefore(".dict") + + // Check if this dictionary type has a corresponding bundled asset for this locale + val hasAsset = if (assetsList != null) { + val matchingAssets = assetsList.filter { it.startsWith("${type}_") } + LocaleUtils.getBestMatch(locale, matchingAssets) { asset -> + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(asset) + } != null + } else { + false + } + + // Check if there is a download preference for this dictionary + val hasDownloadPref = prefs.contains("pref_dict_download_link_${type}_${locale}") + || prefs.contains("pref_dict_download_link_${type}_${locale.toLanguageTag()}") + + var isExtractedAsset = prefs.getBoolean("pref_extracted_asset_${type}_${locale.toLanguageTag()}", false) + || prefs.getBoolean("pref_extracted_asset_${type}_${locale}", false) + + // For backward compatibility, check if the file size matches the current asset + if (!isExtractedAsset && hasAsset && assetsList != null) { + val matchingAssets = assetsList.filter { it.startsWith("${type}_") } + val bestAsset = LocaleUtils.getBestMatch(locale, matchingAssets) { asset -> + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(asset) + } + if (bestAsset != null) { + runCatching { + context.assets.open("${DictionaryInfoUtils.ASSETS_DICTIONARY_FOLDER}/$bestAsset").use { input -> + if (file.length() == input.available().toLong()) { + isExtractedAsset = true + prefs.edit().putBoolean("pref_extracted_asset_${type}_${locale.toLanguageTag()}", true).apply() + } + } + } + } + } + + // Only delete if it is an asset-backed dictionary, was marked as extracted, and wasn't downloaded by the user + if (hasAsset && isExtractedAsset && !hasDownloadPref) { file.delete() + } } } if (oldVersion <= 1000) { // upgrade old custom layouts name diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index 50b67c15d..d19177ee0 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -39,7 +39,17 @@ class ClipboardHistoryManager( // allocating a fresh Handler on every postDelayed(). private val mainHandler = Handler(Looper.getMainLooper()) private var clipboardSuggestionView: View? = null - private var clipboardDao: ClipboardDao? = null + private var _clipboardDao: ClipboardDao? = null + private var clipboardDao: ClipboardDao? + get() { + if (_clipboardDao == null || _clipboardDao?.isClosed == true) { + _clipboardDao = ClipboardDao.getInstance(latinIME) + } + return _clipboardDao + } + set(value) { + _clipboardDao = value + } private var dontShowCurrentSuggestion: Boolean = false private var mediaStoreObserver: ContentObserver? = null // ponytail: track last clip state to avoid resetting dismiss state on duplicate events diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 999722534..e7d219e52 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -88,6 +88,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { Dictionary.TYPE_MAIN, Dictionary.TYPE_CONTACTS, Dictionary.TYPE_APPS, + Dictionary.TYPE_USER, Dictionary.TYPE_USER_HISTORY ) // Caches for spell checking word validity @@ -214,11 +215,13 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { listener?.onUpdateMainDictionaryAvailability(hasAtLeastOneInitializedMainDictionary()) - // Clean up old dictionaries. - existingDictsToCleanup.forEach { (locale, dictTypes) -> - val dictGroupToCleanup = findDictionaryGroupWithLocale(oldDictionaryGroups, locale) ?: return@forEach - for (dictType in dictTypes) { - dictGroupToCleanup.closeDict(dictType) + // Clean up old dictionaries in the background to avoid blocking the main thread. + scope.launch { + existingDictsToCleanup.forEach { (locale, dictTypes) -> + val dictGroupToCleanup = findDictionaryGroupWithLocale(oldDictionaryGroups, locale) ?: return@forEach + for (dictType in dictTypes) { + dictGroupToCleanup.closeDict(dictType) + } } } @@ -312,9 +315,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } listener?.onUpdateMainDictionaryAvailability(hasAtLeastOneInitializedMainDictionary()) - latchForWaitingLoadingMainDictionary.countDown() } catch (e: Throwable) { Log.e(TAG, "could not initialize main dictionaries for $locales", e) + } finally { + latchForWaitingLoadingMainDictionary.countDown() } } } @@ -575,19 +579,31 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { settingsValuesForSuggestion: SettingsValuesForSuggestion, sessionId: Int, inputStyle: Int ): SuggestionResults { val proximityInfoHandle = keyboard.proximityInfo.nativeProximityInfo - val weightOfLangModelVsSpatialModel = floatArrayOf(Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL) - val waitForOtherDicts = if (dictionaryGroups.size == 1) null else CountDownLatch(dictionaryGroups.size - 1) - val suggestionsArray = Array?>(dictionaryGroups.size) { null } - for (i in 1..dictionaryGroups.lastIndex) { + val dictionaryGroupsSnapshot = dictionaryGroups + val waitForOtherDicts = if (dictionaryGroupsSnapshot.size == 1) null else CountDownLatch(dictionaryGroupsSnapshot.size - 1) + val suggestionsArray = Array?>(dictionaryGroupsSnapshot.size) { null } + for (i in 1..dictionaryGroupsSnapshot.lastIndex) { + val dictionaryGroup = dictionaryGroupsSnapshot[i] scope.launch { - suggestionsArray[i] = getSuggestions(composedData, ngramContext, settingsValuesForSuggestion, sessionId, - proximityInfoHandle, weightOfLangModelVsSpatialModel, dictionaryGroups[i]) - waitForOtherDicts?.countDown() + try { + // Native suggestion generation writes back into this one-element array; + // each parallel dictionary needs independent mutable state. + val dictionaryWeight = floatArrayOf(Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL) + suggestionsArray[i] = getSuggestions(composedData, ngramContext, settingsValuesForSuggestion, sessionId, + proximityInfoHandle, dictionaryWeight, dictionaryGroup, dictionaryGroupsSnapshot) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "could not get suggestions for ${dictionaryGroup.locale}", e) + } finally { + waitForOtherDicts?.countDown() + } } } + val primaryDictionaryWeight = floatArrayOf(Dictionary.NOT_A_WEIGHT_OF_LANG_MODEL_VS_SPATIAL_MODEL) suggestionsArray[0] = getSuggestions(composedData, ngramContext, settingsValuesForSuggestion, sessionId, - proximityInfoHandle, weightOfLangModelVsSpatialModel, dictionaryGroups[0]) + proximityInfoHandle, primaryDictionaryWeight, dictionaryGroupsSnapshot[0], dictionaryGroupsSnapshot) val suggestionResults = SuggestionResults( SuggestedWords.MAX_SUGGESTIONS, ngramContext.isBeginningOfSentenceContext, false ) @@ -618,10 +634,11 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private fun getSuggestions( composedData: ComposedData, ngramContext: NgramContext, settingsValuesForSuggestion: SettingsValuesForSuggestion, sessionId: Int, - proximityInfoHandle: Long, weightOfLangModelVsSpatialModel: FloatArray, dictGroup: DictionaryGroup + proximityInfoHandle: Long, weightOfLangModelVsSpatialModel: FloatArray, dictGroup: DictionaryGroup, + allDictionaryGroups: List ): List { val suggestions = ArrayList() - val weightForLocale = dictGroup.getWeightForLocale(dictionaryGroups, composedData.mIsBatchMode) + val weightForLocale = dictGroup.getWeightForLocale(allDictionaryGroups, composedData.mIsBatchMode) for (dictType in DictionaryFacilitator.ALL_DICTIONARY_TYPES) { val dictionary = dictGroup.getDict(dictType) ?: continue var dictionarySuggestions = dictionary.getSuggestions(composedData, ngramContext, proximityInfoHandle, @@ -1067,9 +1084,9 @@ private class DictionaryGroup( private fun rebuildCompiledPatterns(patterns: Collection) { compiledBlacklistPatterns = patterns.map { pattern -> try { - Regex(pattern, RegexOption.IGNORE_CASE) + Regex(pattern) } catch (e: Exception) { - Regex(Regex.escape(pattern), RegexOption.IGNORE_CASE) + Regex(Regex.escape(pattern)) } } } @@ -1101,7 +1118,8 @@ private class DictionaryGroup( fun isBlacklisted(word: String): Boolean { val patterns = compiledBlacklistPatterns - return patterns.any { it.matches(word) } + val lowercased = word.lowercase(locale) + return patterns.any { it.matches(lowercased) } } fun addToBlacklist(word: String) { diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index d445b25ad..3d3b74408 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -12,6 +12,10 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.res.AssetManager; +import helium314.keyboard.latin.utils.LocaleUtils; +import helium314.keyboard.latin.utils.DeviceProtectedUtils; +import helium314.keyboard.latin.settings.Defaults; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Color; @@ -119,6 +123,10 @@ public class LatinIME extends InputMethodService implements private static final String SCHEME_PACKAGE = "package"; final Settings mSettings; + public static volatile boolean sSettingsDirty = true; + private Locale mLastSettingsLocale; + private int mLastInputType; + private int mLastOrientation; public final KeyboardActionListener mKeyboardActionListener; private int mOriginalNavBarColor = 0; private int mOriginalNavBarFlags = 0; @@ -535,6 +543,52 @@ public void onFinishInput() { JniUtils.loadNativeLibrary(); } + private String mAppliedLanguage = ""; + private Context mWrappedContext = null; + + private void updateWrappedContext() { + final android.content.SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(this); + final String lang = prefs.getString(Settings.PREF_APP_LANGUAGE, Defaults.PREF_APP_LANGUAGE); + if (lang == null) return; + if (!lang.equals(mAppliedLanguage) || mWrappedContext == null) { + mAppliedLanguage = lang; + mWrappedContext = LocaleUtils.INSTANCE.wrapContextWithLocale(getBaseContext(), lang); + } + } + + @Override + protected void attachBaseContext(Context newBase) { + final android.content.SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(newBase); + final String lang = prefs.getString(Settings.PREF_APP_LANGUAGE, Defaults.PREF_APP_LANGUAGE); + mAppliedLanguage = lang; + mWrappedContext = LocaleUtils.INSTANCE.wrapContextWithLocale(newBase, lang); + super.attachBaseContext(mWrappedContext); + } + + @Override + public Resources getResources() { + if (mWrappedContext != null) { + return mWrappedContext.getResources(); + } + return super.getResources(); + } + + @Override + public AssetManager getAssets() { + if (mWrappedContext != null) { + return mWrappedContext.getAssets(); + } + return super.getAssets(); + } + + @Override + public Resources.Theme getTheme() { + if (mWrappedContext != null) { + return mWrappedContext.getTheme(); + } + return super.getTheme(); + } + public LatinIME() { super(); mSettings = Settings.getInstance(); @@ -547,6 +601,7 @@ public LatinIME() { @Override public void onCreate() { + updateWrappedContext(); helium314.keyboard.latin.gesture.SwipeGestureEngine.initialize(this); mSettings.startListener(); KeyboardIconsSet.Companion.getInstance().loadIcons(this); @@ -605,6 +660,21 @@ public void onCreate() { private void loadSettings() { final Locale locale = mRichImm.getCurrentSubtypeLocale(); final EditorInfo editorInfo = getCurrentInputEditorInfo(); + final int inputType = editorInfo != null ? editorInfo.inputType : 0; + final int orientation = getResources().getConfiguration().orientation; + + if (!sSettingsDirty + && java.util.Objects.equals(locale, mLastSettingsLocale) + && inputType == mLastInputType + && orientation == mLastOrientation) { + return; + } + + sSettingsDirty = false; + mLastSettingsLocale = locale; + mLastInputType = inputType; + mLastOrientation = orientation; + final InputAttributes inputAttributes = new InputAttributes( editorInfo, isFullscreenMode(), getPackageName()); final String currentKeyboardScript = mKeyboardSwitcher.getCurrentKeyboardScript(); @@ -641,6 +711,12 @@ public void onUpdateMainDictionaryAvailability(final boolean isMainDictionaryAva if (mainKeyboardView != null) { mainKeyboardView.setMainDictionaryAvailability(isMainDictionaryAvailable); } + if (isMainDictionaryAvailable) { + final Keyboard keyboard = mKeyboardSwitcher.getKeyboard(); + if (keyboard != null) { + mInputLogic.getSuggest().buildGestureIndexAsync(keyboard); + } + } if (mHandler.hasPendingWaitForDictionaryLoad()) { mHandler.cancelWaitForDictionaryLoad(); mHandler.postResumeSuggestions(false /* shouldDelay */); @@ -741,6 +817,7 @@ private boolean isImeSuppressedByHardwareKeyboard() { @Override public void onConfigurationChanged(final Configuration conf) { + updateWrappedContext(); SettingsValues settingsValues = mSettings.getCurrent(); Log.i(TAG, "onConfigurationChanged"); SubtypeSettings.INSTANCE.reloadSystemLocales(this); @@ -844,6 +921,7 @@ public void onStartInput(final EditorInfo editorInfo, final boolean restarting) @Override public void onStartInputView(final EditorInfo editorInfo, final boolean restarting) { + updateWrappedContext(); mHandler.onStartInputView(editorInfo, restarting); mStatsUtilsManager.onStartInputView(); } @@ -1071,6 +1149,10 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart mainKeyboardView.closing(); suggest.setAutoCorrectionThreshold(currentSettingsValues.mAutoCorrectionThreshold); switcher.reloadMainKeyboard(); + final Keyboard keyboard = switcher.getKeyboard(); + if (keyboard != null) { + suggest.buildGestureIndexAsync(keyboard); + } if (needToCallLoadKeyboardLater) { // If we need to call loadKeyboard again later, we need to save its state now. // The @@ -1513,9 +1595,19 @@ public void switchToNextSubtype() { final boolean switchSubtype = mSettings.getCurrent().mLanguageSwitchKeyToOtherSubtypes; final boolean switchIme = mSettings.getCurrent().mLanguageSwitchKeyToOtherImes; + final android.content.SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(this); + final String target = prefs.getString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, Defaults.PREF_DIRECT_IME_SWITCH_TARGET); + final boolean hasDirectTarget = target != null && !target.isEmpty(); + // switch IME if wanted and possible - if (switchIme && !switchSubtype && ImeCompat.INSTANCE.switchInputMethod(this)) - return; + if (switchIme && !switchSubtype) { + if (hasDirectTarget) { + switchToUserIme(); + return; + } else if (ImeCompat.INSTANCE.switchInputMethod(this)) { + return; + } + } final boolean hasMoreThanOneSubtype = mRichImm.hasMultipleEnabledSubtypesInThisIme(true); // switch subtype if wanted, do nothing if no other subtype is available if (switchSubtype && !switchIme) { @@ -1536,6 +1628,9 @@ public void switchToNextSubtype() { if (nextSubtype != null) { switchToSubtype(nextSubtype); return; + } else if (hasDirectTarget) { + switchToUserIme(); + return; } else if (ImeCompat.INSTANCE.switchInputMethod(this)) { return; } @@ -1544,9 +1639,8 @@ public void switchToNextSubtype() { } public void switchToUserIme() { - final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils - .getSharedPreferences(this); - final String target = prefs.getString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, helium314.keyboard.latin.settings.Defaults.PREF_DIRECT_IME_SWITCH_TARGET); + final android.content.SharedPreferences prefs = DeviceProtectedUtils.getSharedPreferences(this); + final String target = prefs.getString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, Defaults.PREF_DIRECT_IME_SWITCH_TARGET); if (target == null || target.isEmpty()) { return; } @@ -1583,9 +1677,9 @@ public void switchToUserIme() { switchToSubtype(targetSubtype); } } else if (targetSubtype != null) { - ImeCompat.INSTANCE.switchInputMethodAndSubtype(this, targetImi, targetSubtype); + ImeCompat.INSTANCE.switchInputMethodAndSubtypeCompat(this, targetImi, targetSubtype); } else { - switchInputMethod(targetImi.getId()); + ImeCompat.INSTANCE.switchInputMethodCompat(this, targetImi.getId()); } } @@ -1805,11 +1899,10 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { if (suggestionInfo.isKindOf(helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo.KIND_CORRECTION) && helium314.keyboard.latin.dictionary.Dictionary.DICTIONARY_USER_TYPED.equals( suggestionInfo.mSourceDict != null ? suggestionInfo.mSourceDict.mDictType : "")) { - helium314.keyboard.latin.gesture.SwipeGestureEngine.recordAccepted( + mInputLogic.getSuggest().recordAccepted( suggestionInfo.mWord, mInputLogic.getWordComposer().getComposedDataSnapshot().mInputPointers, - mKeyboardSwitcher.getKeyboard(), - mInputLogic.getSuggest().getGestureIndex() + mKeyboardSwitcher.getKeyboard() ); } } diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt index 0c0244894..bd6702b3c 100644 --- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt +++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt @@ -49,8 +49,29 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { @Volatile private var gestureIndex: SwipeGestureEngine.GestureIndex? = null @Volatile private var gestureIndexFingerprint: Int = 0 + fun buildGestureIndexAsync(keyboard: Keyboard) { + val fingerprint = SwipeGestureEngine.layoutFingerprint(keyboard) + if (gestureIndex != null && gestureIndexFingerprint == fingerprint) { + return + } + Thread { + try { + val index = SwipeGestureEngine.buildIndex(mDictionaryFacilitator, keyboard) + gestureIndex = index + gestureIndexFingerprint = fingerprint + } catch (t: Throwable) { + Log.e(TAG, "Failed to build Java/JNI gesture index", t) + gestureIndex = null + } + }.start() + } + fun getGestureIndex(): SwipeGestureEngine.GestureIndex? = gestureIndex + fun recordAccepted(word: String, pointers: InputPointers, keyboard: Keyboard) { + SwipeGestureEngine.recordAccepted(word, pointers, keyboard, gestureIndex) + } + // Cached scoreLimit to avoid repeated Settings lookups in hot path // The read-then-write of (mLastScoreLimitUpdateTime, mCachedScoreLimitForAutocorrect) // is guarded by `synchronized(this)` in shouldBeAutoCorrected() to make the update atomic @@ -343,26 +364,25 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { inputStyle: Int, sequenceNumber: Int ): SuggestedWords { val pointers = wordComposer.composedDataSnapshot.mInputPointers - val useFallback = "fallback" == settingsValuesForSuggestion.mGestureMethod || !JniUtils.sHaveNativeGestureLib + val method = settingsValuesForSuggestion.mGestureMethod + val useFallback = "fallback" == method || !JniUtils.sHaveNativeGestureLib val suggestionResults = if (useFallback) { val fingerprint = SwipeGestureEngine.layoutFingerprint(keyboard) - var index = gestureIndex - if (index == null || index.byFirst.isEmpty() || gestureIndexFingerprint != fingerprint) { - index = SwipeGestureEngine.buildIndex(mDictionaryFacilitator, keyboard) - if (index.byFirst.isNotEmpty()) { - gestureIndex = index - gestureIndexFingerprint = fingerprint - } - } - val predictionSet = if (ngramContext.isValid) { - mDictionaryFacilitator.getSuggestionResults( - ComposedData(InputPointers(32), false, ""), ngramContext, keyboard, - settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle - ).map { it.mWord.lowercase(Locale.ROOT) }.toSet() + val index = gestureIndex + if (index == null || gestureIndexFingerprint != fingerprint) { + buildGestureIndexAsync(keyboard) + SuggestionResults(1, false, false) } else { - emptySet() + val predictionSet = if (ngramContext.isValid) { + mDictionaryFacilitator.getSuggestionResults( + ComposedData(InputPointers(32), false, ""), ngramContext, keyboard, + settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle + ).map { it.mWord.lowercase(Locale.ROOT) }.toSet() + } else { + emptySet() + } + SwipeGestureEngine.rankByIndex(index, pointers, keyboard, SuggestedWords.MAX_SUGGESTIONS, predictionSet) } - SwipeGestureEngine.rankByIndex(index, pointers, keyboard, SuggestedWords.MAX_SUGGESTIONS, predictionSet) } else { mDictionaryFacilitator.getSuggestionResults( wordComposer.composedDataSnapshot, ngramContext, keyboard, diff --git a/app/src/main/java/helium314/keyboard/latin/database/ClipboardDao.kt b/app/src/main/java/helium314/keyboard/latin/database/ClipboardDao.kt index adff05bdc..9a124dfee 100644 --- a/app/src/main/java/helium314/keyboard/latin/database/ClipboardDao.kt +++ b/app/src/main/java/helium314/keyboard/latin/database/ClipboardDao.kt @@ -31,6 +31,9 @@ class ClipboardDao private constructor(private val db: Database) { var listener: Listener? = null + var isClosed = false + private set + // we clean up old clips when a new clip is added, but not too frequently private var lastClearOldClips = 0L @@ -303,5 +306,14 @@ class ClipboardDao private constructor(private val db: Database) { } return instance } + + @Synchronized + fun closeInstance() { + instance?.let { + it.isClosed = true + it.db.close() + } + instance = null + } } } diff --git a/app/src/main/java/helium314/keyboard/latin/database/Database.kt b/app/src/main/java/helium314/keyboard/latin/database/Database.kt index 0e9c785cd..15653e29a 100644 --- a/app/src/main/java/helium314/keyboard/latin/database/Database.kt +++ b/app/src/main/java/helium314/keyboard/latin/database/Database.kt @@ -58,5 +58,11 @@ class Database private constructor(context: Context, name: String = NAME) : SQLi otherDb.close() file.delete() } + + @Synchronized + fun closeInstance() { + instance?.close() + instance = null + } } } diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java index c908c0fc9..161737bc0 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java @@ -98,6 +98,7 @@ abstract public class ExpandableBinaryDictionary extends Dictionary { private boolean mNeedsToRecreate; private final ReentrantReadWriteLock mLock; + private final Object mIterationLock = new Object(); /* A extension for a binary dictionary file. */ protected static final String DICT_FILE_EXTENSION = ".dict"; @@ -427,66 +428,70 @@ protected boolean isInDictionaryLocked(final String word) { @Override public Map getAllWordsWithFrequency() { - Map words = new java.util.HashMap<>(); - boolean lockAcquired = false; - try { - lockAcquired = mLock.readLock().tryLock( - TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); - if (lockAcquired) { - if (mBinaryDictionary == null || !mBinaryDictionary.isValidDictionary()) { - return words; - } - int token = 0; - do { - BinaryDictionary.GetNextWordAndFrequencyResult result = - mBinaryDictionary.getNextWordAndFrequency(token); - if (result.mWordAndFrequency == null) break; - String word = result.mWordAndFrequency.mWord; - int freq = result.mWordAndFrequency.mFrequency; - if (word != null && !word.isEmpty() && freq >= 0) { - words.put(word, freq); + synchronized (mIterationLock) { + Map words = new java.util.HashMap<>(); + boolean lockAcquired = false; + try { + lockAcquired = mLock.readLock().tryLock( + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + if (lockAcquired) { + if (mBinaryDictionary == null || !mBinaryDictionary.isValidDictionary()) { + return words; } - token = result.mNextToken; - } while (token != 0); - } - } catch (final InterruptedException e) { - Log.e(TAG, "Interrupted tryLock() in getAllWordsWithFrequency().", e); - } finally { - if (lockAcquired) { - mLock.readLock().unlock(); + int token = 0; + do { + BinaryDictionary.GetNextWordAndFrequencyResult result = + mBinaryDictionary.getNextWordAndFrequency(token); + if (result.mWordAndFrequency == null) break; + String word = result.mWordAndFrequency.mWord; + int freq = result.mWordAndFrequency.mFrequency; + if (word != null && !word.isEmpty() && freq >= 0) { + words.put(word, freq); + } + token = result.mNextToken; + } while (token != 0); + } + } catch (final InterruptedException e) { + Log.e(TAG, "Interrupted tryLock() in getAllWordsWithFrequency().", e); + } finally { + if (lockAcquired) { + mLock.readLock().unlock(); + } } + return words; } - return words; } @Override public void forEachWord(java.util.function.BiConsumer consumer) { - boolean lockAcquired = false; - try { - lockAcquired = mLock.readLock().tryLock( - TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); - if (lockAcquired) { - if (mBinaryDictionary == null || !mBinaryDictionary.isValidDictionary()) { - return; - } - int token = 0; - do { - BinaryDictionary.GetNextWordAndFrequencyResult result = - mBinaryDictionary.getNextWordAndFrequency(token); - if (result.mWordAndFrequency == null) break; - String word = result.mWordAndFrequency.mWord; - int freq = result.mWordAndFrequency.mFrequency; - if (word != null && !word.isEmpty() && freq >= 0) { - consumer.accept(word, freq); + synchronized (mIterationLock) { + boolean lockAcquired = false; + try { + lockAcquired = mLock.readLock().tryLock( + TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS); + if (lockAcquired) { + if (mBinaryDictionary == null || !mBinaryDictionary.isValidDictionary()) { + return; } - token = result.mNextToken; - } while (token != 0); - } - } catch (final InterruptedException e) { - Log.e(TAG, "Interrupted tryLock() in forEachWord().", e); - } finally { - if (lockAcquired) { - mLock.readLock().unlock(); + int token = 0; + do { + BinaryDictionary.GetNextWordAndFrequencyResult result = + mBinaryDictionary.getNextWordAndFrequency(token); + if (result.mWordAndFrequency == null) break; + String word = result.mWordAndFrequency.mWord; + int freq = result.mWordAndFrequency.mFrequency; + if (word != null && !word.isEmpty() && freq >= 0) { + consumer.accept(word, freq); + } + token = result.mNextToken; + } while (token != 0); + } + } catch (final InterruptedException e) { + Log.e(TAG, "Interrupted tryLock() in forEachWord().", e); + } finally { + if (lockAcquired) { + mLock.readLock().unlock(); + } } } } @@ -669,29 +674,31 @@ public void dumpAllWordsForDebug() { final String tag = TAG; final String dictName = mDictName; asyncExecuteTaskWithLock(mLock.readLock(), () -> { - Log.d(tag, "Dump dictionary: " + dictName + " for " + mLocale); - final BinaryDictionary binaryDictionary = getBinaryDictionary(); - if (binaryDictionary == null) { - return; - } - try { - final DictionaryHeader header = binaryDictionary.getHeader(); - Log.d(tag, "Format version: " + binaryDictionary.getFormatVersion()); - Log.d(tag, CombinedFormatUtils.formatAttributeMap(header.mDictionaryOptions.mAttributes)); - } catch (final UnsupportedFormatException e) { - Log.d(tag, "Cannot fetch header information.", e); - } - int token = 0; - do { - final BinaryDictionary.GetNextWordPropertyResult result = binaryDictionary.getNextWordProperty(token); - final WordProperty wordProperty = result.mWordProperty; - if (wordProperty == null) { - Log.d(tag, " dictionary is empty."); - break; + synchronized (mIterationLock) { + Log.d(tag, "Dump dictionary: " + dictName + " for " + mLocale); + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; } - Log.d(tag, wordProperty.toString()); - token = result.mNextToken; - } while (token != 0); + try { + final DictionaryHeader header = binaryDictionary.getHeader(); + Log.d(tag, "Format version: " + binaryDictionary.getFormatVersion()); + Log.d(tag, CombinedFormatUtils.formatAttributeMap(header.mDictionaryOptions.mAttributes)); + } catch (final UnsupportedFormatException e) { + Log.d(tag, "Cannot fetch header information.", e); + } + int token = 0; + do { + final BinaryDictionary.GetNextWordPropertyResult result = binaryDictionary.getNextWordProperty(token); + final WordProperty wordProperty = result.mWordProperty; + if (wordProperty == null) { + Log.d(tag, " dictionary is empty."); + break; + } + Log.d(tag, wordProperty.toString()); + token = result.mNextToken; + } while (token != 0); + } }); } @@ -702,24 +709,26 @@ public WordProperty[] getWordPropertiesForSyncing() { reloadDictionaryIfRequired(); final AsyncResultHolder result = new AsyncResultHolder<>("WordPropertiesForSync"); asyncExecuteTaskWithLock(mLock.readLock(), () -> { - final ArrayList wordPropertyList = new ArrayList<>(); - final BinaryDictionary binaryDictionary = getBinaryDictionary(); - if (binaryDictionary == null) { - return; - } - int token = 0; - do { - // TODO: We need a new API that returns *new* un-synced data. - final BinaryDictionary.GetNextWordPropertyResult nextWordPropertyResult = binaryDictionary - .getNextWordProperty(token); - final WordProperty wordProperty = nextWordPropertyResult.mWordProperty; - if (wordProperty == null) { - break; + synchronized (mIterationLock) { + final ArrayList wordPropertyList = new ArrayList<>(); + final BinaryDictionary binaryDictionary = getBinaryDictionary(); + if (binaryDictionary == null) { + return; } - wordPropertyList.add(wordProperty); - token = nextWordPropertyResult.mNextToken; - } while (token != 0); - result.set(wordPropertyList.toArray(new WordProperty[0])); + int token = 0; + do { + // TODO: We need a new API that returns *new* un-synced data. + final BinaryDictionary.GetNextWordPropertyResult nextWordPropertyResult = binaryDictionary + .getNextWordProperty(token); + final WordProperty wordProperty = nextWordPropertyResult.mWordProperty; + if (wordProperty == null) { + break; + } + wordPropertyList.add(wordProperty); + token = nextWordPropertyResult.mNextToken; + } while (token != 0); + result.set(wordPropertyList.toArray(new WordProperty[0])); + } }); // TODO: Figure out the best timeout duration for this API. return result.get(DEFAULT_WORD_PROPERTIES_FOR_SYNC, TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS); diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java index ae17e1063..e786bb262 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java @@ -31,6 +31,7 @@ public final class ReadOnlyBinaryDictionary extends Dictionary { * that change the state of dictionary. */ private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock(); + private final Object mIterationLock = new Object(); private final BinaryDictionary mBinaryDictionary; @@ -114,96 +115,92 @@ public int getMaxFrequencyOfExactMatches(final String word) { @Override @androidx.annotation.NonNull public Map getAllWordsWithFrequency() { - Map words = new HashMap<>(); - int token = 0; - int count = 0; - do { - if (!mLock.readLock().tryLock()) { - try { - Thread.sleep(5); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - continue; - } - try { - if (!mBinaryDictionary.isValidDictionary()) { - break; + synchronized (mIterationLock) { + Map words = new HashMap<>(); + int token = 0; + int count = 0; + do { + if (!mLock.readLock().tryLock()) { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + continue; } - BinaryDictionary.GetNextWordAndFrequencyResult result = - mBinaryDictionary.getNextWordAndFrequency(token); - if (result.mWordAndFrequency == null) break; - String word = result.mWordAndFrequency.mWord; - int freq = result.mWordAndFrequency.mFrequency; - if (word != null && !word.isEmpty() && freq >= 0) { - words.put(word, freq); + try { + if (!mBinaryDictionary.isValidDictionary()) { + break; + } + BinaryDictionary.GetNextWordAndFrequencyResult result = + mBinaryDictionary.getNextWordAndFrequency(token); + if (result.mWordAndFrequency == null) break; + String word = result.mWordAndFrequency.mWord; + int freq = result.mWordAndFrequency.mFrequency; + if (word != null && !word.isEmpty() && freq >= 0) { + words.put(word, freq); + } + token = result.mNextToken; + } finally { + mLock.readLock().unlock(); } - token = result.mNextToken; - } finally { - mLock.readLock().unlock(); - } - count++; - if (count % 200 == 0) { - Thread.yield(); - } - if (count % 2000 == 0) { - try { - Thread.sleep(1); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + count++; + if (count % 200 == 0) { + Thread.yield(); } - } - } while (token != 0); - return words; + if (count % 2000 == 0) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } while (token != 0); + return words; + } } @Override public void forEachWord(java.util.function.BiConsumer consumer) { - int token = 0; - int count = 0; - do { - if (!mLock.readLock().tryLock()) { - try { - Thread.sleep(5); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - continue; - } + synchronized (mIterationLock) { + mLock.readLock().lock(); try { if (!mBinaryDictionary.isValidDictionary()) { - break; - } - BinaryDictionary.GetNextWordAndFrequencyResult result = - mBinaryDictionary.getNextWordAndFrequency(token); - if (result.mWordAndFrequency == null) break; - String word = result.mWordAndFrequency.mWord; - int freq = result.mWordAndFrequency.mFrequency; - if (word != null && !word.isEmpty() && freq >= 0) { - consumer.accept(word, freq); + return; } - token = result.mNextToken; + int token = 0; + int count = 0; + do { + BinaryDictionary.GetNextWordAndFrequencyResult result = + mBinaryDictionary.getNextWordAndFrequency(token); + if (result.mWordAndFrequency == null) break; + String word = result.mWordAndFrequency.mWord; + int freq = result.mWordAndFrequency.mFrequency; + if (word != null && !word.isEmpty() && freq >= 0) { + consumer.accept(word, freq); + } + token = result.mNextToken; + + count++; + if (count % 200 == 0) { + Thread.yield(); + } + if (count % 2000 == 0) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } while (token != 0); } finally { mLock.readLock().unlock(); } - - count++; - if (count % 200 == 0) { - Thread.yield(); - } - if (count % 2000 == 0) { - try { - Thread.sleep(1); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - } while (token != 0); + } } @Override diff --git a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java b/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java index abb8f8133..fd2a0b99f 100644 --- a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java +++ b/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java @@ -240,15 +240,15 @@ public void unpackPath(float[] out) { public static class GestureIndex { public final Map> byFirst; // ponytail: store charToPos in index so rankByIndex doesn't rebuild it every call - public final float[][] charToPos; - GestureIndex(Map> byFirst, float[][] charToPos) { + public final Map charToPos; + GestureIndex(Map> byFirst, Map charToPos) { this.byFirst = byFirst; this.charToPos = charToPos; } } public static GestureIndex buildIndex(helium314.keyboard.latin.DictionaryFacilitator facilitator, Keyboard keyboard) { - float[][] charToPos = buildCharToPos(keyboard); + Map charToPos = buildCharToPos(keyboard); Map> byFirst = new HashMap<>(); facilitator.forEachMainDictionaryWord((raw, freqVal) -> { if (raw == null) return; @@ -262,7 +262,7 @@ public static GestureIndex buildIndex(helium314.keyboard.latin.DictionaryFacilit String word = lk; if (word.isEmpty()) return; char first = word.charAt(0); - if (first < 'a' || first > 'z') return; + if (!charToPos.containsKey(first)) return; float[] path = wordPath(word, charToPos); byFirst.computeIfAbsent(first, k -> new ArrayList<>()) .add(new IndexEntry(word, path, freq)); @@ -274,7 +274,13 @@ public static GestureIndex buildIndex(helium314.keyboard.latin.DictionaryFacilit } public static int layoutFingerprint(Keyboard keyboard) { - return Arrays.deepHashCode(buildCharToPos(keyboard)); + Map map = buildCharToPos(keyboard); + Object[] values = new Object[map.size()]; + int idx = 0; + for (float[] p : map.values()) { + values[idx++] = p; + } + return Arrays.deepHashCode(values); } // ── Public matching API ─────────────────────────────────────────────────── @@ -284,20 +290,20 @@ private static boolean isAsciiLetter(int code) { } // ponytail: use charToPos directly instead of iterating all keys on every gesture - private static List nearestLettersFromMap(float nx, float ny, float[][] charToPos) { + private static List nearestLettersFromMap(float nx, float ny, Map charToPos) { float minDist = Float.MAX_VALUE; - float[] dists = new float[26]; - for (int i = 0; i < 26; i++) { - float cx = charToPos[i][0], cy = charToPos[i][1]; - if (cx == 0f && cy == 0f) { dists[i] = Float.MAX_VALUE; continue; } + Map dists = new HashMap<>(); + for (Map.Entry entry : charToPos.entrySet()) { + float[] pos = entry.getValue(); + float cx = pos[0], cy = pos[1]; float d = (nx - cx) * (nx - cx) + (ny - cy) * (ny - cy); - dists[i] = d; + dists.put(entry.getKey(), d); if (d < minDist) minDist = d; } List results = new ArrayList<>(4); float threshold = minDist + 0.035f; - for (int i = 0; i < 26; i++) { - if (dists[i] <= threshold) results.add((char) ('a' + i)); + for (Map.Entry entry : dists.entrySet()) { + if (entry.getValue() <= threshold) results.add(entry.getKey()); } return results; } @@ -325,7 +331,7 @@ private static float sqDistanceToSegment(float px, float py, float ax, float ay, return (px - closestX) * (px - closestX) + (py - closestY) * (py - closestY); } - public static boolean isSequenceMatch(String word, float[] path, float[][] charToPos) { + public static boolean isSequenceMatch(String word, float[] path, Map charToPos) { int n = path.length / 2; int segmentIdx = 0; float prevT = -0.01f; @@ -333,10 +339,9 @@ public static boolean isSequenceMatch(String word, float[] path, float[][] charT float[] outT = new float[1]; for (int i = 0; i < word.length(); i++) { char c = word.charAt(i); - if (c < 'a' || c > 'z') continue; if (c == lastChar) continue; - float[] target = charToPos[c - 'a']; - if (target[0] == 0f && target[1] == 0f) continue; + float[] target = charToPos.get(c); + if (target == null) continue; boolean found = false; while (segmentIdx < n - 1) { float distSq = sqDistanceToSegment(target[0], target[1], @@ -375,7 +380,7 @@ public static SuggestionResults rankByIndex( float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; // ponytail: use charToPos from index — already built, no reallocation - float[][] charToPos = index.charToPos; + Map charToPos = index.charToPos; List startLetters = nearestLettersFromMap(xs[0] / kw, ys[0] / kh, charToPos); List endLetters = nearestLettersFromMap(xs[n-1] / kw, ys[n-1] / kh, charToPos); @@ -410,6 +415,7 @@ public static SuggestionResults rankByIndex( // ponytail: parallel float[] + int[] sort avoids Integer boxing float[] scores = new float[m]; int[] order = new int[m]; + int count = 0; float[] topScores = new float[maxResults]; Arrays.fill(topScores, -Float.MAX_VALUE); float threshold = -Float.MAX_VALUE; @@ -418,46 +424,55 @@ public static SuggestionResults rankByIndex( for (int i = 0; i < m; i++) { IndexEntry e = filtered.get(i); String lower = getLowerCase(e.word); - e.unpackPath(candidatePath); - boolean seqMatch = isSequenceMatch(lower, inputVec, charToPos); - float seqPenalty = seqMatch ? 0f : -0.4f; boolean isPredicted = predictionSet != null && predictionSet.contains(lower); float predBonus = isPredicted ? 0.15f : 0f; float lenPenalty = -Math.abs(inputLength - e.pathLen) * 0.4f; Integer ub = sUserBoost.get(lower); float userBonus = ub != null ? sUserBoostCache[ub] : 0f; - float bonuses = e.freqBonus + seqPenalty + predBonus + lenPenalty + userBonus; - float maxL2 = (threshold == -Float.MAX_VALUE) ? Float.MAX_VALUE : (bonuses - threshold); - float distance; - if (maxL2 <= 0f) { - distance = Float.MAX_VALUE; - } else { - distance = l2(inputVec, candidatePath, maxL2); + float bonuses = e.freqBonus + predBonus + lenPenalty + userBonus; + if (bonuses < threshold) { + continue; + } + + boolean seqMatch = isSequenceMatch(lower, inputVec, charToPos); + float seqPenalty = seqMatch ? 0f : -0.4f; + float scoreWithSeq = bonuses + seqPenalty; + if (scoreWithSeq < threshold) { + continue; } - float score = -distance + bonuses; - scores[i] = score; - order[i] = i; + e.unpackPath(candidatePath); + float maxL2 = (threshold == -Float.MAX_VALUE) ? Float.MAX_VALUE : (scoreWithSeq - threshold); + float distance = l2(inputVec, candidatePath, maxL2); + float score = -distance + scoreWithSeq; + + scores[count] = score; + order[count] = i; + count++; if (score > threshold) { threshold = updateThreshold(topScores, score); } } + if (count == 0) return empty; + // ponytail: primitive int sort with insertion sort for small N (fast for <500 items) - for (int i = 1; i < m; i++) { + for (int i = 1; i < count; i++) { int key = order[i]; - float ks = scores[key]; + float ks = scores[i]; int j = i - 1; - while (j >= 0 && scores[order[j]] < ks) { + while (j >= 0 && scores[j] < ks) { + scores[j + 1] = scores[j]; order[j + 1] = order[j]; j--; } + scores[j + 1] = ks; order[j + 1] = key; } - int take = Math.min(maxResults, m); + int take = Math.min(maxResults, count); SuggestionResults result = new SuggestionResults(take, false, false); int baseScore = 1_000_000; for (int rank = 0; rank < take; rank++) { @@ -487,29 +502,27 @@ private static float pathLength(float[] path) { return len; } - static float[][] buildCharToPos(Keyboard keyboard) { - float[][] map = new float[26][2]; + static Map buildCharToPos(Keyboard keyboard) { + Map map = new HashMap<>(); float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight; for (Key key : keyboard.getSortedKeys()) { int code = key.getCode(); - if (!isAsciiLetter(code)) continue; - int idx = Character.toLowerCase((char) code) - 'a'; + if (code <= 0) continue; + char c = Character.toLowerCase((char) code); Rect hitBox = key.getHitBox(); - map[idx][0] = hitBox.exactCenterX() / kw; - map[idx][1] = hitBox.exactCenterY() / kh; + map.put(c, new float[]{hitBox.exactCenterX() / kw, hitBox.exactCenterY() / kh}); } return map; } - static float[] wordPath(String word, float[][] charToPos) { + static float[] wordPath(String word, Map charToPos) { float[] pts = new float[word.length() * 2]; int count = 0; float lastX = -1f, lastY = -1f; for (int i = 0; i < word.length(); i++) { char c = word.charAt(i); - int idx = c - 'a'; - if (idx < 0 || idx >= 26) continue; - float[] p = charToPos[idx]; + float[] p = charToPos.get(c); + if (p == null) continue; if (count == 0 || p[0] != lastX || p[1] != lastY) { pts[2 * count] = p[0]; pts[2 * count + 1] = p[1]; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 75f15a0d6..86877cd6e 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -51,6 +51,7 @@ object Defaults { const val PREF_SPLIT_TOOLBAR = false const val PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR = true + const val PREF_USE_SYSTEM_EMOJI = false private const val DEFAULT_SIZE_SCALE = 1.0f // 100% @@ -90,6 +91,7 @@ object Defaults { const val PREF_SHOW_LANGUAGE_SWITCH_KEY = false const val PREF_LANGUAGE_SWITCH_KEY = "internal" const val PREF_DIRECT_IME_SWITCH_TARGET = "" + const val PREF_APP_LANGUAGE = "" const val PREF_SHOW_EMOJI_KEY = false const val PREF_VARIABLE_TOOLBAR_DIRECTION = true const val PREF_ADDITIONAL_SUBTYPES = "de${Separators.SET}${ExtraValue.KEYBOARD_LAYOUT_SET}=MAIN:qwerty${Separators.SETS}" + @@ -135,7 +137,7 @@ object Defaults { const val PREF_SUGGEST_SCREENSHOTS = false const val PREF_COMPRESS_SCREENSHOTS = true const val PREF_AUTO_READ_OTP = false - const val PREF_GESTURE_INPUT = true + const val PREF_GESTURE_INPUT = false // ponytail: gesture method default value const val PREF_GESTURE_METHOD = "fallback" const val PREF_VIBRATION_DURATION_SETTINGS = -1 @@ -242,6 +244,7 @@ object Defaults { const val PREF_AUTO_SHOW_TOOLBAR = false const val PREF_AUTO_SHOW_TOOLBAR_ON_SELECT = false const val PREF_AUTO_HIDE_TOOLBAR = true + const val PREF_TOOLBAR_SWIPE_DOWN_DISMISS = false const val PREF_AUTO_HIDE_PINNED_KEYS = true const val PREF_REMEMBER_TOOLBAR_STATE = false const val PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE = false diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index c3433d26a..16d9de240 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -93,6 +93,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_SHOW_LANGUAGE_SWITCH_KEY = "show_language_switch_key"; public static final String PREF_LANGUAGE_SWITCH_KEY = "language_switch_key"; public static final String PREF_DIRECT_IME_SWITCH_TARGET = "direct_ime_switch_target"; + public static final String PREF_APP_LANGUAGE = "pref_app_language"; public static final String PREF_SHOW_EMOJI_KEY = "show_emoji_key"; public static final String PREF_VARIABLE_TOOLBAR_DIRECTION = "var_toolbar_direction"; public static final String PREF_ADDITIONAL_SUBTYPES = "additional_subtypes"; @@ -104,6 +105,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_SIDE_PADDING_SCALE_PREFIX = "side_padding_scale"; public static final String PREF_FONT_SCALE = "font_scale"; public static final String PREF_EMOJI_FONT_SCALE = "emoji_font_scale"; + public static final String PREF_USE_SYSTEM_EMOJI = "use_system_emoji"; public static final String PREF_EMOJI_KEY_FIT = "emoji_key_fit"; public static final String PREF_EMOJI_SKIN_TONE = "emoji_skin_tone"; public static final String PREF_SPACE_HORIZONTAL_SWIPE = "horizontal_space_swipe"; @@ -267,6 +269,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_AUTO_SHOW_TOOLBAR = "auto_show_toolbar"; public static final String PREF_AUTO_SHOW_TOOLBAR_ON_SELECT = "auto_show_toolbar_on_select"; public static final String PREF_AUTO_HIDE_TOOLBAR = "auto_hide_toolbar"; + public static final String PREF_TOOLBAR_SWIPE_DOWN_DISMISS = "toolbar_swipe_down_dismiss"; public static final String PREF_AUTO_HIDE_PINNED_KEYS = "auto_hide_pinned_keys"; public static final String PREF_REMEMBER_TOOLBAR_STATE = "remember_toolbar_state"; public static final String PREF_TOOLBAR_EXPANDED = "toolbar_expanded"; @@ -371,6 +374,7 @@ public void onSharedPreferenceChanged(final SharedPreferences prefs, final Strin ToolbarUtilsKt.clearCustomToolbarKeyCodes(); loadSettings(mContext, mSettingsValues.mLocale, mSettingsValues.mInputAttributes, mSettingsValues.mCurrentKeyboardScript); StatsUtils.onLoadSettings(mSettingsValues); + helium314.keyboard.latin.LatinIME.sSettingsDirty = true; } finally { mSettingsValuesLock.unlock(); } @@ -725,6 +729,10 @@ private boolean isSubtypePerApp() { return mPrefs.getBoolean(PREF_SAVE_SUBTYPE_PER_APP, Defaults.PREF_SAVE_SUBTYPE_PER_APP); } + public boolean useSystemEmoji() { + return mPrefs.getBoolean(PREF_USE_SYSTEM_EMOJI, Defaults.PREF_USE_SYSTEM_EMOJI); + } + @Nullable public Typeface getCustomTypeface() { if (!sCustomTypefaceLoaded) { diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 656bcf397..9f79e0d7e 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -177,6 +177,7 @@ public class SettingsValues { public final boolean mAutoShowToolbar; public final boolean mAutoShowToolbarOnSelect; public final boolean mAutoHideToolbar; + public final boolean mToolbarSwipeDownDismiss; public final boolean mAutoHidePinnedKeys; public final boolean mRememberToolbarState; public final boolean mToolbarSwipeDownToHide; @@ -361,8 +362,7 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_KEYPRESS_SOUND_VOLUME); mEnableEmojiAltPhysicalKey = prefs.getBoolean(Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY, Defaults.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY); - mGestureMethod = prefs.getString(Settings.PREF_GESTURE_METHOD, - JniUtils.sHaveNativeGestureLib ? "native" : "fallback"); + mGestureMethod = prefs.getString(Settings.PREF_GESTURE_METHOD, "fallback"); mGestureInputEnabled = JniUtils.sHaveGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT) && (!"native".equals(mGestureMethod) || JniUtils.sHaveNativeGestureLib); @@ -544,6 +544,7 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina && prefs.getBoolean(Settings.PREF_AUTO_SHOW_TOOLBAR, Defaults.PREF_AUTO_SHOW_TOOLBAR); mAutoHideToolbar = mSuggestionsEnabledPerUserSettings && prefs.getBoolean(Settings.PREF_AUTO_HIDE_TOOLBAR, Defaults.PREF_AUTO_HIDE_TOOLBAR); + mToolbarSwipeDownDismiss = prefs.getBoolean(Settings.PREF_TOOLBAR_SWIPE_DOWN_DISMISS, Defaults.PREF_TOOLBAR_SWIPE_DOWN_DISMISS); mAutoHidePinnedKeys = mToolbarMode == ToolbarMode.EXPANDABLE && !mSplitToolbar && prefs.getBoolean(Settings.PREF_AUTO_HIDE_PINNED_KEYS, Defaults.PREF_AUTO_HIDE_PINNED_KEYS); diff --git a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java index 08e73d6bb..bbbbc0650 100644 --- a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java +++ b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidWordLevelSpellCheckerSession.java @@ -57,7 +57,7 @@ public abstract class AndroidWordLevelSpellCheckerSession extends Session { protected final SuggestionsCache mSuggestionsCache = new SuggestionsCache(); private final ContentObserver mObserver; - private static final String quotesRegexp = "([\\u0022\\u0027\\u0060\\u00B4\\u2018\\u2018\\u201C\\u201D])"; + private static final String quotesRegexp = "([\\u0022\\u0027\\u0060\\u00B4\\u2018\\u2019\\u201C\\u201D])"; private static final Map scriptToPunctuationRegexMap = new TreeMap<>(); @@ -126,6 +126,7 @@ private void updateLocale() { : LocaleUtils.constructLocale(localeString); if (mLocale == null) mScript = ScriptUtils.SCRIPT_UNKNOWN; else mScript = ScriptUtils.script(mLocale); + mSuggestionsCache.clearCache(); } } @@ -280,6 +281,13 @@ protected SuggestionsInfo onGetSuggestionsInternal( text = text.replaceAll(localeRegex, ""); } + final SuggestionsParams cachedSuggestionsParams = + mSuggestionsCache.getSuggestionsFromCache(text); + if (cachedSuggestionsParams != null) { + return new SuggestionsInfo(cachedSuggestionsParams.mFlags, + cachedSuggestionsParams.mSuggestions); + } + if (!mService.hasMainDictionaryForLocale(mLocale)) { return AndroidSpellCheckerService.getNotInDictEmptySuggestions(false /* reportAsTypo */); } diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index ac4a6b84a..14699e61f 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -24,6 +24,7 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.View.OnLongClickListener +import android.view.ViewConfiguration import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.widget.ImageButton @@ -142,6 +143,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) // Translate language selector private var isTranslateLanguageSelectorVisible = false + private val translateLanguageContainer: View = findViewById(R.id.translate_language_container) private val translateLanguageSelector: ViewGroup = findViewById(R.id.translate_language_selector) private val translateLanguageCloseButton: ImageButton by lazy { findViewById(R.id.translate_language_close_button) @@ -258,6 +260,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) 1f ) suggestionsStrip.layoutParams = suggestionsParams + translateLanguageContainer.layoutParams = suggestionsParams } if (Settings.getValues().mSplitToolbar) { @@ -315,6 +318,20 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) gestureDetector = GestureDetector(context, slidingListener) } + private var swipeDownDismissed = false + private val swipeDownDetector = GestureDetector(context, object : SimpleOnGestureListener() { + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + if (!Settings.getValues().mToolbarSwipeDownDismiss) return false + val minVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity * 1.5f + if (velocityY > minVelocity && Math.abs(velocityY) > Math.abs(velocityX)) { + swipeDownDismissed = true + listener.onCodeInput(KeyCode.IME_HIDE_UI, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, false) + return true + } + return false + } + }) + // public stuff val isShowingMoreSuggestionPanel get() = moreSuggestionsView.isShowingInParent @@ -567,6 +584,15 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) return false } + // Detect swipe-down to dismiss keyboard + if (Settings.getValues().mToolbarSwipeDownDismiss) { + swipeDownDetector.onTouchEvent(motionEvent) + if (swipeDownDismissed) { + swipeDownDismissed = false + return true + } + } + // In split mode, don't intercept touches on the top row (toolbar row) // to prevent accidentally cancelling long presses on toolbar buttons. if (Settings.getValues().mSplitToolbar) { @@ -800,12 +826,31 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) pinnedKeys.findViewWithTag(ToolbarKey.VOICE)?.isVisible = show } + private fun getLanguageHistory(prefs: SharedPreferences): List> { + val historyString = prefs.getString("pref_translation_language_history", "") ?: "" + if (historyString.isEmpty()) return emptyList() + return historyString.split("\n").mapNotNull { + val parts = it.split("|", limit = 2) + if (parts.size == 2) parts[1] to parts[0] else null + } + } + + private fun saveLanguageHistory(prefs: SharedPreferences, name: String, code: String) { + val currentHistory = getLanguageHistory(prefs).toMutableList() + currentHistory.removeAll { it.second == code } + currentHistory.add(0, name to code) + val serialized = currentHistory.joinToString("\n") { "${it.second}|${it.first}" } + prefs.edit().putString("pref_translation_language_history", serialized).apply() + } + fun showTranslateLanguageSelector() { // Hide other views suggestionsStrip.isVisible = false - toolbarContainer.isVisible = false - pinnedKeys.isVisible = false - toolbarExpandKey.isVisible = false + if (!Settings.getValues().mSplitToolbar) { + toolbarContainer.isVisible = false + pinnedKeys.isVisible = false + toolbarExpandKey.isVisible = false + } // Populate language buttons val languageList = findViewById(R.id.translate_language_list) @@ -815,12 +860,22 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) val languageCodes = resources.getStringArray(R.array.translate_language_codes) val prefs = context.prefs() - val list = languageNames.zip(languageCodes).toMutableList() + val defaultList = languageNames.zip(languageCodes).toMutableList() val currentLanguageCode = prefs.getString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, "English") ?: "English" val currentLanguageName = prefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, currentLanguageCode) ?: currentLanguageCode + + val history = getLanguageHistory(prefs).toMutableList() + if (currentLanguageCode.isNotEmpty() && history.none { it.second == currentLanguageCode }) { + history.add(0, currentLanguageName to currentLanguageCode) + } - if (!languageCodes.contains(currentLanguageCode) && currentLanguageCode.isNotEmpty()) { - list.add(0, currentLanguageName to currentLanguageCode) + val list = mutableListOf>() + list.addAll(history) + val historyCodes = history.map { it.second }.toSet() + for (item in defaultList) { + if (item.second !in historyCodes) { + list.add(item) + } } // Create a button for each language @@ -845,12 +900,15 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) putString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, languageName) putString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, languageCode) }.apply() + saveLanguageHistory(context.prefs(), languageName, languageCode) helium314.keyboard.latin.utils.ProofreadService(context).setTargetLanguage(languageCode) hideTranslateLanguageSelector() listener.onCodeInput(KeyCode.TRANSLATE, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, false) } - Settings.getValues().mColors.setColor(button.background, ColorType.TOOL_BAR_KEY) button.setBackgroundResource(R.drawable.toolbar_key_background) + val colors = Settings.getValues().mColors + colors.setColor(button.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) + button.setTextColor(colors.get(ColorType.KEY_TEXT)) languageList.addView(button) } @@ -884,6 +942,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) putString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, customLang) putString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, customLang) }.apply() + saveLanguageHistory(prefs, customLang, customLang) helium314.keyboard.latin.utils.ProofreadService(context).setTargetLanguage(customLang) hideTranslateLanguageSelector() listener.onCodeInput(KeyCode.TRANSLATE, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, false) @@ -893,17 +952,17 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } builder.show() } - Settings.getValues().mColors.setColor(customButton.background, ColorType.TOOL_BAR_KEY) customButton.setBackgroundResource(R.drawable.toolbar_key_background) + val colors = Settings.getValues().mColors + colors.setColor(customButton.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) + customButton.setTextColor(colors.get(ColorType.KEY_TEXT)) languageList.addView(customButton) // Setup close button - translateLanguageCloseButton.isVisible = true translateLanguageCloseButton.setBackgroundResource(R.drawable.toolbar_key_background) val closePadding = 9.dpToPx(resources) translateLanguageCloseButton.setPadding(closePadding, closePadding, closePadding, closePadding) translateLanguageCloseButton.setImageDrawable(KeyboardIconsSet.instance.getNewDrawable(ToolbarKey.CLOSE_HISTORY.name, context)) - val colors = Settings.getValues().mColors colors.setColor(translateLanguageCloseButton, ColorType.TOOL_BAR_EXPAND_KEY) colors.setColor(translateLanguageCloseButton.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) translateLanguageCloseButton.setOnClickListener { @@ -911,7 +970,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } // Show the selector - translateLanguageSelector.isVisible = true + translateLanguageContainer.isVisible = true isTranslateLanguageSelectorVisible = true } @@ -921,8 +980,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } fun hideTranslateLanguageSelector() { - translateLanguageSelector.isVisible = false - translateLanguageCloseButton.isVisible = false + translateLanguageContainer.isVisible = false // Restore normal view val settingsValues = Settings.getValues() diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt index c5d1ad87b..50074c63e 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt @@ -186,9 +186,11 @@ object DictionaryInfoUtils { val targetFile = File(cacheDir, "${dictionaryFileName.substringBefore("_")}.dict") try { FileUtils.copyStreamToNewFile( - context.assets.open(ASSETS_DICTIONARY_FOLDER + File.separator + dictionaryFileName), + context.assets.open("$ASSETS_DICTIONARY_FOLDER/$dictionaryFileName"), targetFile ) + val type = dictionaryFileName.substringBefore("_") + context.prefs().edit().putBoolean("pref_extracted_asset_${type}_${locale.toLanguageTag()}", true).apply() } catch (e: IOException) { Log.e(TAG, "Could not extract assets dictionary $dictionaryFileName", e) return null diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt index 91c572fc9..e2b946d29 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt @@ -4,6 +4,7 @@ package helium314.keyboard.latin.utils import android.content.Context import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -128,6 +130,8 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline if (availableDicts.isNotEmpty() && knownDicts.isEmpty()) annotatedString += AnnotatedString("\n") + availableDicts + var refreshTrigger by remember { mutableStateOf(0) } + if (inline) { ConfirmationDialogContent( onDismissRequest = onDismissRequest, @@ -140,7 +144,7 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline if (knownDicts.isNotEmpty()) { androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) knownDicts.forEach { (desc, link) -> - DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {}) + DownloadableDictionaryRow(locale = locale, desc = desc, link = link, refreshTrigger = refreshTrigger, onRefresh = { refreshTrigger++ }) } } } @@ -158,7 +162,7 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline if (knownDicts.isNotEmpty()) { androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) knownDicts.forEach { (desc, link) -> - DownloadableDictionaryRow(locale = locale, desc = desc, link = link, onRefresh = {}) + DownloadableDictionaryRow(locale = locale, desc = desc, link = link, refreshTrigger = refreshTrigger, onRefresh = { refreshTrigger++ }) } } } @@ -274,11 +278,15 @@ fun downloadDictionary(context: Context, locale: Locale, type: String, linkUrl: } if (status == java.net.HttpURLConnection.HTTP_OK) { + val lastModified = conn.lastModified conn.inputStream.use { input -> targetFile.outputStream().use { output -> input.copyTo(output) } } + if (lastModified > 0L) { + targetFile.setLastModified(lastModified) + } success = true } else { Log.e("DictionaryUtils", "HTTP error downloading dictionary: $status") @@ -306,10 +314,51 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, refres val file = remember(cacheDir, type) { cacheDir?.let { File(it, "$type.dict") } } var downloading by remember { mutableStateOf(false) } val downloadedLink = remember(link, refreshTrigger) { ctx.prefs().getString("pref_dict_download_link_${type}_${dictLocale}", "") ?: "" } - var exists by remember(file, downloadedLink, refreshTrigger) { - mutableStateOf( - file?.exists() == true && (downloadedLink == link || (downloadedLink.isEmpty() && link.contains("/dictionaries/"))) - ) + val isInstalled = remember(file, downloadedLink, link, refreshTrigger) { + file?.exists() == true && (downloadedLink == link || (downloadedLink.isEmpty() && !link.contains("experimental"))) + } + var onlineLastModified by remember(link) { mutableStateOf(0L) } + LaunchedEffect(link, isInstalled) { + if (isInstalled) { + withContext(Dispatchers.IO) { + try { + val url = java.net.URL(link) + val connection = url.openConnection() as java.net.HttpURLConnection + connection.requestMethod = "HEAD" + connection.setRequestProperty("User-Agent", "HeliboardL/3.8.9 (Android)") + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.instanceFollowRedirects = true + var status = connection.responseCode + var conn = connection + var redirectCount = 0 + while ((status == java.net.HttpURLConnection.HTTP_MOVED_TEMP || + status == java.net.HttpURLConnection.HTTP_MOVED_PERM || + status == 307 || status == 308) && redirectCount < 5) { + val newUrl = conn.getHeaderField("Location") ?: break + conn.disconnect() + val nextUrl = java.net.URL(newUrl) + conn = nextUrl.openConnection() as java.net.HttpURLConnection + conn.requestMethod = "HEAD" + conn.setRequestProperty("User-Agent", "HeliboardL/3.8.9 (Android)") + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + conn.instanceFollowRedirects = true + status = conn.responseCode + redirectCount++ + } + if (status == java.net.HttpURLConnection.HTTP_OK) { + onlineLastModified = conn.lastModified + } + conn.disconnect() + } catch (e: Exception) { + android.util.Log.e("DictionaryUtils", "Failed to check online last modified", e) + } + } + } + } + val hasUpgrade = remember(isInstalled, file, onlineLastModified, refreshTrigger) { + isInstalled && file != null && onlineLastModified > 0L && onlineLastModified > file.lastModified() } Row( @@ -317,8 +366,51 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, refres verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) ) { - Text(desc, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) - if (exists) { + Column(modifier = Modifier.weight(1f)) { + Text(desc, style = MaterialTheme.typography.bodyMedium) + if (hasUpgrade && !downloading) { + Text( + text = stringResource(R.string.dictionary_update_available), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodySmall + ) + } + } + if (downloading) { + Text( + stringResource(R.string.downloading), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp) + ) + } else if (hasUpgrade) { + Row(verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.TextButton( + onClick = { + downloading = true + downloadDictionary(ctx, dictLocale, type, link) { success -> + downloading = false + if (success) { + ctx.prefs().edit().putString("pref_dict_download_link_${type}_${dictLocale}", link).apply() + onRefresh() + } else { + android.widget.Toast.makeText(ctx, ctx.getString(R.string.download_failed), android.widget.Toast.LENGTH_SHORT).show() + } + } + }, + modifier = Modifier.padding(end = 4.dp) + ) { + Text(stringResource(R.string.upgrade)) + } + helium314.keyboard.settings.DeleteButton( + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) { + file?.delete() + ctx.prefs().edit().remove("pref_dict_download_link_${type}_${dictLocale}").apply() + onRefresh() + } + } + } else if (isInstalled) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "✓ " + stringResource(R.string.installed), @@ -332,16 +424,9 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, refres ) { file?.delete() ctx.prefs().edit().remove("pref_dict_download_link_${type}_${dictLocale}").apply() - exists = false onRefresh() } } - } else if (downloading) { - Text( - stringResource(R.string.downloading), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 8.dp) - ) } else { androidx.compose.material3.TextButton(onClick = { downloading = true @@ -349,7 +434,6 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, refres downloading = false if (success) { ctx.prefs().edit().putString("pref_dict_download_link_${type}_${dictLocale}", link).apply() - exists = true onRefresh() } else { android.widget.Toast.makeText(ctx, ctx.getString(R.string.download_failed), android.widget.Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/helium314/keyboard/latin/utils/InputMethodPicker.kt b/app/src/main/java/helium314/keyboard/latin/utils/InputMethodPicker.kt index 8a66bf48a..5d94ef6ee 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/InputMethodPicker.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/InputMethodPicker.kt @@ -11,7 +11,8 @@ import android.text.style.RelativeSizeSpan import android.view.WindowManager import android.view.inputmethod.InputMethodInfo import android.view.inputmethod.InputMethodSubtype -import helium314.keyboard.compat.ImeCompat.switchInputMethodAndSubtype +import helium314.keyboard.compat.ImeCompat.switchInputMethodCompat +import helium314.keyboard.compat.ImeCompat.switchInputMethodAndSubtypeCompat import helium314.keyboard.latin.LatinIME import helium314.keyboard.latin.R import helium314.keyboard.latin.RichInputMethodManager @@ -69,9 +70,9 @@ fun createInputMethodPickerDialog(latinIme: LatinIME, richImm: RichInputMethodMa if (imi == thisImi) latinIme.switchToSubtype(subtype) else if (subtype != null) - latinIme.switchInputMethodAndSubtype(imi, subtype) + latinIme.switchInputMethodAndSubtypeCompat(imi, subtype) else - latinIme.switchInputMethod(imi.id) + latinIme.switchInputMethodCompat(imi.id) } .create() diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LocaleUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/LocaleUtils.kt new file mode 100644 index 000000000..95e7bfea4 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/LocaleUtils.kt @@ -0,0 +1,51 @@ +package helium314.keyboard.latin.utils + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import helium314.keyboard.latin.R +import java.util.Locale + +object LocaleUtils { + fun wrapContextWithLocale(context: Context, localeTag: String): Context { + if (localeTag.isEmpty() || localeTag == "system") { + return context + } + val locale = if (localeTag.contains("-")) { + val parts = localeTag.split("-") + Locale(parts[0], parts[1]) + } else { + Locale(localeTag) + } + Locale.setDefault(locale) + val config = Configuration(context.resources.configuration) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val localeList = android.os.LocaleList(locale) + config.setLocales(localeList) + } else { + @Suppress("DEPRECATION") + config.locale = locale + } + return context.createConfigurationContext(config) + } + + val localeCodes = listOf( + "en", "af", "am", "ar", "as", "ast", "az", "be", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "dv", "el", "es", "es-US", "et", "eu", "fa", "fi", "fil", "fr", "gd", "gl", "gu", "hi", "hr", "hu", "hy", "in", "is", "it", "iw", "ja", "ka", "kab", "kk", "km", "kn", "ko", "kw", "ky", "lb", "lo", "lt", "lv", "mk", "ml", "mn", "mr", "ms", "my", "nb", "ne", "nl", "or", "pa", "pl", "pt", "pt-BR", "pt-PT", "ro", "ru", "si", "sk", "sl", "sq", "sr", "sv", "sw", "ta", "te", "tg", "th", "tl", "tr", "uk", "ur", "uz", "vi", "zh-CN", "zh-HK", "zh-TW", "zu" + ) + + fun getAppLanguageItems(context: Context): List> { + val items = mutableListOf>() + items.add(context.getString(R.string.app_language_system) to "") + for (code in localeCodes) { + val locale = if (code.contains("-")) { + val parts = code.split("-") + Locale(parts[0], parts[1]) + } else { + Locale(code) + } + val name = locale.getDisplayName(locale).replaceFirstChar { it.uppercase() } + items.add(name to code) + } + return items + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt index cdda7a066..9b666da37 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only package helium314.keyboard.settings +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri @@ -28,12 +29,14 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import helium314.keyboard.compat.locale import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.latin.utils.LocaleUtils import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.InputAttributes import helium314.keyboard.latin.R import helium314.keyboard.latin.common.FileUtils import helium314.keyboard.latin.define.DebugFlags import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.utils.DeviceProtectedUtils import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.UncachedInputMethodManagerUtils @@ -55,6 +58,13 @@ import java.util.zip.ZipOutputStream // https://developer.android.com/topic/performance/baselineprofiles/overview // todo: consider viewModel, at least for LanguageScreen and ColorsScreen it might help making them less awkward and complicated open class SettingsActivity : ComponentActivity(), SharedPreferences.OnSharedPreferenceChangeListener { + override fun attachBaseContext(newBase: Context) { + val prefs = DeviceProtectedUtils.getSharedPreferences(newBase) + val lang = prefs.getString(Settings.PREF_APP_LANGUAGE, Defaults.PREF_APP_LANGUAGE) ?: Defaults.PREF_APP_LANGUAGE + val wrapped = LocaleUtils.wrapContextWithLocale(newBase, lang) + super.attachBaseContext(wrapped) + } + private val prefs by lazy { this.prefs() } val prefChanged = MutableStateFlow(0) // simple counter, as the only relevant information is that something changed fun prefChanged() = prefChanged.value++ @@ -262,6 +272,9 @@ open class SettingsActivity : ComponentActivity(), SharedPreferences.OnSharedPre override fun onSharedPreferenceChanged(prefereces: SharedPreferences?, key: String?) { prefChanged() + if (key == Settings.PREF_APP_LANGUAGE) { + recreate() + } } } diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt index bc6aad141..28d8a189d 100644 --- a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt +++ b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt @@ -236,7 +236,7 @@ fun WelcomeWizard( mutableStateOf( ctx.prefs().getString( Settings.PREF_GESTURE_METHOD, - if (JniUtils.sHaveNativeGestureLib) "native" else "fallback" + "fallback" )!! ) } @@ -302,7 +302,7 @@ fun WelcomeWizard( SubtypeSettings.addEnabledSubtype(ctx.prefs(), subtype) } } - enabledSubtypes.forEach { subtype -> + enabledSubtypes.toList().forEach { subtype -> if (subtype !in selected && selected.isNotEmpty()) { SubtypeSettings.removeEnabledSubtype(ctx, subtype) } @@ -325,7 +325,7 @@ fun WelcomeWizard( ) { DropDownField( items = gestureMethods, - selectedItem = gestureMethods.first { it.second == selectedMethod }, + selectedItem = gestureMethods.firstOrNull { it.second == selectedMethod } ?: gestureMethods.first(), onSelected = { pair -> selectedMethod = pair.second ctx.prefs().edit { putString(Settings.PREF_GESTURE_METHOD, pair.second) } diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index 45eb7c09b..7855c9f12 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -34,6 +34,7 @@ import helium314.keyboard.latin.AppUpgrade import helium314.keyboard.latin.R import helium314.keyboard.latin.common.FileUtils import helium314.keyboard.latin.database.Database +import helium314.keyboard.latin.database.ClipboardDao import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.DeviceProtectedUtils import helium314.keyboard.latin.utils.ExecutorUtils @@ -303,6 +304,8 @@ private fun restoreLauncher( } } if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + ClipboardDao.closeInstance() + Database.closeInstance() ctx.deleteDatabase(Database.NAME) } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt index 9421483f9..f7a8a474f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AppearanceScreen.kt @@ -87,6 +87,7 @@ fun AppearanceScreen( Settings.PREF_FONT_SCALE, SettingsWithoutKey.CUSTOM_EMOJI_FONT, Settings.PREF_EMOJI_FONT_SCALE, + Settings.PREF_USE_SYSTEM_EMOJI, if (prefs.getFloat(Settings.PREF_EMOJI_FONT_SCALE, Defaults.PREF_EMOJI_FONT_SCALE) != 1f) Settings.PREF_EMOJI_KEY_FIT else null, if (prefs.getInt(Settings.PREF_EMOJI_MAX_SDK, 0) >= 24) @@ -303,6 +304,13 @@ fun createAppearanceSettings(context: Context) = listOf( description = { "${(100 * it).toInt()}%" } ) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } }, + Setting(context, Settings.PREF_USE_SYSTEM_EMOJI, R.string.prefs_use_system_emoji, R.string.prefs_use_system_emoji_summary) { setting -> + val ctx = LocalContext.current + SwitchPreference(setting, Defaults.PREF_USE_SYSTEM_EMOJI) { newValue -> + ctx.prefs().edit(commit = true) { putBoolean(Settings.PREF_USE_SYSTEM_EMOJI, newValue) } + Runtime.getRuntime().exit(0) + } + }, Setting(context, Settings.PREF_EMOJI_KEY_FIT, R.string.prefs_emoji_key_fit) { SwitchPreference(it, Defaults.PREF_EMOJI_KEY_FIT) { KeyboardSwitcher.getInstance().setThemeNeedsReload() } }, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt index d7c49765d..4ecb49e26 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt @@ -72,7 +72,7 @@ private fun loadBlockedWords(context: Context): List { } } val uniqueList = list.distinct() - return uniqueList.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) + return uniqueList.sortedWith(compareBy({ it.word.lowercase(it.locale) }, { it.locale.toLanguageTag() })) } private fun addBlockedWord(context: Context, word: String, locale: Locale) { @@ -132,7 +132,11 @@ fun BlockedWordsScreen( stringResource(R.string.clear_all) to { showClearAllDialog = true } ), filteredItems = { term -> - blockedWords.filter { it.word.startsWith(term, true) } + blockedWords.filter { + val termLower = term.lowercase(it.locale) + val wordLower = it.word.lowercase(it.locale) + wordLower.startsWith(termLower) + } }, itemContent = { item -> Row( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index a18f7a0b2..89e931f1c 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -190,6 +190,46 @@ fun DictionaryScreen( } NextScreenIcon() } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + // Dictionary Source Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(helium314.keyboard.latin.common.Links.DICTIONARY_URL)) + ctx.startActivity(intent) + } + .padding(vertical = 14.dp, horizontal = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(R.drawable.ic_settings_about_github), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 12.dp).size(24.dp) + ) + Column { + Text( + stringResource(R.string.dictionary_source_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + stringResource(R.string.dictionary_source_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + NextScreenIcon() + } } } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index 5c2b1d9c6..134d6baff 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -40,7 +40,7 @@ fun GestureTypingScreen( val hasGestureLib = JniUtils.sHaveGestureLib val gestureFloatingPreviewEnabled = prefs.getBoolean(Settings.PREF_GESTURE_FLOATING_PREVIEW_TEXT, Defaults.PREF_GESTURE_FLOATING_PREVIEW_TEXT) val gestureEnabled = hasGestureLib && prefs.getBoolean(Settings.PREF_GESTURE_INPUT, Defaults.PREF_GESTURE_INPUT) - val selectedMethod = prefs.getString(Settings.PREF_GESTURE_METHOD, if (JniUtils.sHaveNativeGestureLib) "native" else "fallback") + val selectedMethod = prefs.getString(Settings.PREF_GESTURE_METHOD, "fallback") // Always show library loader first when no library val items = buildList { @@ -102,7 +102,7 @@ fun createGestureTypingSettings(context: Context) = listOf( stringResource(R.string.gesture_method_native) to "native", stringResource(R.string.gesture_method_fallback) to "fallback" ) - ListPreference(it, items, if (JniUtils.sHaveNativeGestureLib) "native" else "fallback") + ListPreference(it, items, "fallback") }, Setting(context, Settings.PREF_GESTURE_PREVIEW_TRAIL, R.string.gesture_preview_trail) { SwitchPreference(it, Defaults.PREF_GESTURE_PREVIEW_TRAIL) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt index be038544a..66778aacb 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt @@ -23,6 +23,7 @@ import helium314.keyboard.latin.utils.locale import helium314.keyboard.latin.utils.prefs import helium314.keyboard.latin.RichInputMethodManager import helium314.keyboard.latin.utils.SubtypeLocaleUtils.displayName +import helium314.keyboard.latin.utils.LocaleUtils import helium314.keyboard.settings.preferences.ListPreference import helium314.keyboard.settings.Setting import helium314.keyboard.settings.preferences.ReorderSwitchPreference @@ -46,6 +47,7 @@ fun PreferencesScreen( val clipboardHistoryEnabled = prefs.getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY, Defaults.PREF_ENABLE_CLIPBOARD_HISTORY) val items = listOf( R.string.settings_category_input, + Settings.PREF_APP_LANGUAGE, Settings.PREF_SHOW_HINTS, if (prefs.getBoolean(Settings.PREF_SHOW_HINTS, Defaults.PREF_SHOW_HINTS)) Settings.PREF_POPUP_KEYS_LABELS_ORDER else null, @@ -173,6 +175,13 @@ fun createPreferencesSettings(context: Context) = listOf( Defaults.PREF_DIRECT_IME_SWITCH_TARGET ) }, + Setting(context, Settings.PREF_APP_LANGUAGE, R.string.app_language_title, R.string.app_language_summary) { + ListPreference( + it, + LocaleUtils.getAppLanguageItems(context), + Defaults.PREF_APP_LANGUAGE + ) + }, Setting(context, Settings.PREF_SHOW_EMOJI_KEY, R.string.show_emoji_key) { SwitchPreference(it, Defaults.PREF_SHOW_EMOJI_KEY) { KeyboardSwitcher.getInstance().reloadKeyboard() } }, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt index 4c1c7669c..46290fe87 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/SubtypeScreen.kt @@ -159,7 +159,7 @@ fun SubtypeScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { MainLayoutRow(currentSubtype, customMainLayouts) { setCurrentSubtype(it) } - if (availableLocalesForScript.size > 1) { + if (availableLocalesForScript.isNotEmpty()) { WithSmallTitle(stringResource(R.string.secondary_locale)) { ActionRow(onClick = { showSecondaryLocaleDialog = true }) { val text = getSecondaryLocales(currentSubtype.extraValues).joinToString(", ") { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index 564b59b65..236eaf0eb 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -86,6 +86,7 @@ fun ToolbarScreen( if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_VARIABLE_TOOLBAR_DIRECTION else null, if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE else null, if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD else null, + Settings.PREF_TOOLBAR_SWIPE_DOWN_DISMISS, ) SearchSettingsScreen( onClickBack = onClickBack, @@ -175,6 +176,11 @@ fun createToolbarSettings(context: Context): List { { SwitchPreference(it, Defaults.PREF_REMEMBER_TOOLBAR_STATE) }, + Setting(context, Settings.PREF_TOOLBAR_SWIPE_DOWN_DISMISS, + R.string.toolbar_swipe_down_dismiss, R.string.toolbar_swipe_down_dismiss_summary) + { + SwitchPreference(it, Defaults.PREF_TOOLBAR_SWIPE_DOWN_DISMISS) + }, Setting(context, Settings.PREF_VARIABLE_TOOLBAR_DIRECTION, R.string.var_toolbar_direction, R.string.var_toolbar_direction_summary) { diff --git a/app/src/main/res/layout/suggestions_strip.xml b/app/src/main/res/layout/suggestions_strip.xml index 294a32d91..11639ab5b 100644 --- a/app/src/main/res/layout/suggestions_strip.xml +++ b/app/src/main/res/layout/suggestions_strip.xml @@ -66,30 +66,37 @@ style="?attr/suggestionWordStyle"> - - - - - + android:fillViewport="true" + android:scrollbarThumbHorizontal="@color/toolbar_scrollbar"> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21511250c..35ecc04cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -860,6 +860,10 @@ Set custom font from file Set custom emoji font from file + + Use system emoji font + + Use system emoji font in settings and search (restarts app) English (UK) @@ -1276,6 +1280,9 @@ New dictionary: Direct Switch Target IME Input Method to switch to directly when using the custom keycode option None + App Language + Change the language of the settings screen and keyboard interface + System default Appearance @@ -1420,6 +1427,10 @@ New dictionary: Auto hide toolbar Hide the toolbar when suggestions become available + + Swipe down to close + + Swipe down on toolbar to hide keyboard Auto hide pinned keys @@ -1491,4 +1502,10 @@ New dictionary: Do not exit text editing mode when input finishes Disable multi-word suggestions Prevent suggestions that consist of multiple combined words (recommended for Turkish) + + + Upgrade + Update available + Dictionary repository + Browse or download dictionaries directly in your browser diff --git a/app/src/test/java/helium314/keyboard/Shadows.kt b/app/src/test/java/helium314/keyboard/Shadows.kt index 37675018f..3e2a576fd 100644 --- a/app/src/test/java/helium314/keyboard/Shadows.kt +++ b/app/src/test/java/helium314/keyboard/Shadows.kt @@ -41,6 +41,24 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() { @Implementation fun getShortcutInputMethodsAndSubtypes() = emptyMap>() + @Implementation + fun switchToNextInputMethod(token: android.os.IBinder?, onlyCurrentIme: Boolean): Boolean { + switchedToNextInputMethod = true + return true + } + + @Implementation + fun setInputMethod(token: android.os.IBinder?, id: String) { + switchedImeId = id + switchedSubtype = null + } + + @Implementation + fun setInputMethodAndSubtype(token: android.os.IBinder?, id: String, subtype: InputMethodSubtype) { + switchedImeId = id + switchedSubtype = subtype + } + companion object { private fun defaultInputMethod() = InputMethodInfo( BuildConfig.APPLICATION_ID, @@ -51,10 +69,16 @@ class ShadowInputMethodManager2 : ShadowInputMethodManager() { var inputMethods: List = listOf(defaultInputMethod()) val enabledSubtypes = mutableMapOf>() + var switchedImeId: String? = null + var switchedSubtype: InputMethodSubtype? = null + var switchedToNextInputMethod = false fun reset() { inputMethods = listOf(defaultInputMethod()) enabledSubtypes.clear() + switchedImeId = null + switchedSubtype = null + switchedToNextInputMethod = false } } } diff --git a/app/src/test/java/helium314/keyboard/SubtypeTest.kt b/app/src/test/java/helium314/keyboard/SubtypeTest.kt index a101c07d9..e4792cc4f 100644 --- a/app/src/test/java/helium314/keyboard/SubtypeTest.kt +++ b/app/src/test/java/helium314/keyboard/SubtypeTest.kt @@ -15,6 +15,7 @@ import helium314.keyboard.latin.utils.SubtypeSettings import helium314.keyboard.latin.utils.SubtypeUtilsAdditional import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith +import java.util.Locale import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -42,6 +43,23 @@ class SubtypeTest { addLocaleKeyTextsToParams(latinIME, params, POPUP_KEYS_NORMAL) } + @Test fun testGetDictionaryLocales() { + val prefs = latinIME.prefs() + prefs.edit().putString(Settings.PREF_ENABLED_SUBTYPES, "").apply() + SubtypeSettings.reloadEnabledSubtypes(latinIME) + + val enSubtype = SubtypeSettings.getResourceSubtypesForLocale("en_US".constructLocale()).first() + val frSubtype = SubtypeSettings.getResourceSubtypesForLocale("fr".constructLocale()).first() + + SubtypeSettings.addEnabledSubtype(prefs, enSubtype) + SubtypeSettings.addEnabledSubtype(prefs, frSubtype) + SubtypeSettings.reloadEnabledSubtypes(latinIME) + + val locales = helium314.keyboard.latin.utils.getDictionaryLocales(latinIME) + assertTrue(locales.contains("en_US".constructLocale())) + assertTrue(locales.contains("fr".constructLocale())) + } + @Test fun emptyAdditionalSubtypesResultsInEmptyList() { // avoid issues where empty string results in additional subtype for undefined locale val prefs = latinIME.prefs() diff --git a/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt b/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt new file mode 100644 index 000000000..585ceffa3 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/compat/ImeCompatTest.kt @@ -0,0 +1,99 @@ +package helium314.keyboard.compat + +import android.app.Dialog +import android.content.Context +import android.inputmethodservice.InputMethodService +import android.os.Binder +import android.view.Window +import android.view.WindowManager +import android.view.inputmethod.InputMethodInfo +import android.view.inputmethod.InputMethodSubtype +import androidx.test.core.app.ApplicationProvider +import helium314.keyboard.ShadowInputMethodManager2 +import helium314.keyboard.latin.RichInputMethodManager +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [27], shadows = [ShadowInputMethodManager2::class]) +class ImeCompatTest { + @BeforeTest + fun setUp() { + ShadowInputMethodManager2.reset() + RichInputMethodManager.init(ApplicationProvider.getApplicationContext()) + } + + @Test + fun preAndroidPDirectImeSwitchUsesInputMethodManager() { + val service = serviceWithWindowToken() + + ImeCompat.run { service.switchInputMethodCompat(EXTERNAL_IME.id) } + + assertEquals(EXTERNAL_IME.id, ShadowInputMethodManager2.switchedImeId) + assertNull(ShadowInputMethodManager2.switchedSubtype) + } + + @Test + fun preAndroidPNextImeWithoutWindowTokenIsNoOp() { + val service = serviceWithWindowToken(null) + + val switched = ImeCompat.run { service.switchInputMethod() } + + assertEquals(false, switched) + assertEquals(false, ShadowInputMethodManager2.switchedToNextInputMethod) + } + + @Test + fun preAndroidPDirectImeWithoutWindowTokenIsNoOp() { + val service = serviceWithWindowToken(null) + + ImeCompat.run { service.switchInputMethodCompat(EXTERNAL_IME.id) } + ImeCompat.run { service.switchInputMethodAndSubtypeCompat(EXTERNAL_IME, EXTERNAL_SUBTYPE) } + + assertNull(ShadowInputMethodManager2.switchedImeId) + assertNull(ShadowInputMethodManager2.switchedSubtype) + } + + @Test + fun preAndroidPDirectImeSubtypeSwitchUsesInputMethodManager() { + val service = serviceWithWindowToken() + + ImeCompat.run { service.switchInputMethodAndSubtypeCompat(EXTERNAL_IME, EXTERNAL_SUBTYPE) } + + assertEquals(EXTERNAL_IME.id, ShadowInputMethodManager2.switchedImeId) + assertEquals(EXTERNAL_SUBTYPE, ShadowInputMethodManager2.switchedSubtype) + } + + private fun serviceWithWindowToken(token: android.os.IBinder? = Binder()): InputMethodService { + val service = mock(InputMethodService::class.java) + val dialog = mock(Dialog::class.java) + val window = mock(Window::class.java) + val attributes = WindowManager.LayoutParams().apply { this.token = token } + `when`(service.window).thenReturn(dialog) + `when`(dialog.window).thenReturn(window) + `when`(window.attributes).thenReturn(attributes) + return service + } + + companion object { + private val EXTERNAL_IME = InputMethodInfo( + "example.ime", + "example.ime.Service", + "Example IME", + null, + ) + private val EXTERNAL_SUBTYPE: InputMethodSubtype = InputMethodSubtype.InputMethodSubtypeBuilder() + .setSubtypeId(202) + .setLanguageTag("fr-FR") + .setSubtypeLocale("fr_FR") + .setSubtypeMode("keyboard") + .build() + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/AppUpgradeTest.kt b/app/src/test/java/helium314/keyboard/latin/AppUpgradeTest.kt new file mode 100644 index 000000000..3a70d5f0d --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/AppUpgradeTest.kt @@ -0,0 +1,133 @@ +package helium314.keyboard.latin + +import android.content.Context +import android.content.res.AssetManager +import androidx.core.content.edit +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.DictionaryInfoUtils +import helium314.keyboard.latin.utils.prefs +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class AppUpgradeTest { + + private lateinit var context: Context + private lateinit var mockAssets: AssetManager + + @Before + fun setUp() { + // Create a spy context around Robolectric Application to allow stubbing context.assets + val baseContext = RuntimeEnvironment.getApplication() + context = spy(baseContext) + mockAssets = mock(AssetManager::class.java) + doReturn(mockAssets).`when`(context).assets + + val prefs = baseContext.prefs() + prefs.edit { + clear() + putInt(Settings.PREF_VERSION_CODE, 1) // Force upgrade check + } + + // Clean up any pre-existing cache files + val cacheDir = File(DictionaryInfoUtils.getWordListCacheDirectory(baseContext)) + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + } + + @Test + fun testCheckVersionUpgradePreservesDownloadedAndDeletesAssets() { + // Stub mock assets to simulate that 'bg' has a main dictionary asset + doReturn(arrayOf("main_bg.dict")).`when`(mockAssets).list("dicts") + val mockStream = java.io.ByteArrayInputStream(ByteArray(0)) + doReturn(mockStream).`when`(mockAssets).open("dicts/main_bg.dict") + + // Setup cache directories + // Locale 'bg' exists in assets (main_bg.dict) + val bgDir = File(DictionaryInfoUtils.getCacheDirectoryForLocale(java.util.Locale.forLanguageTag("bg"), context)!!) + bgDir.mkdirs() + val bgMain = File(bgDir, "main.dict").apply { createNewFile() } + val bgUser = File(bgDir, "bg_user.dict").apply { createNewFile() } + val bgEmoji = File(bgDir, "emoji_bg.dict").apply { createNewFile() } + + // Locale 'eo' does NOT exist in assets + val eoDir = File(DictionaryInfoUtils.getCacheDirectoryForLocale(java.util.Locale.forLanguageTag("eo"), context)!!) + eoDir.mkdirs() + val eoMain = File(eoDir, "main.dict").apply { createNewFile() } + val eoUser = File(eoDir, "eo_user.dict").apply { createNewFile() } + + // Setup preferences for downloads + val prefs = context.prefs() + prefs.edit { + putString("pref_dict_download_link_main_eo", "https://example.com/main_eo.dict") + putString("pref_dict_download_link_emoji_bg", "https://example.com/emoji_bg.dict") + } + + // Run checkVersionUpgrade + AppUpgrade.checkVersionUpgrade(context) + + // Verify: + // 1. bg/main.dict is asset-backed and has no download link preference -> Should be DELETED + assertFalse("bg/main.dict should be deleted", bgMain.exists()) + + // 2. bg/bg_user.dict ends with USER_DICTIONARY_SUFFIX -> Should NOT be deleted + assertTrue("bg/bg_user.dict should not be deleted", bgUser.exists()) + + // 3. bg/emoji_bg.dict has a download link preference -> Should NOT be deleted + assertTrue("bg/emoji_bg.dict should not be deleted", bgEmoji.exists()) + + // 4. eo/main.dict is not asset-backed and has download link preference -> Should NOT be deleted + assertTrue("eo/main.dict should not be deleted", eoMain.exists()) + + // 5. eo/eo_user.dict ends with USER_DICTIONARY_SUFFIX -> Should NOT be deleted + assertTrue("eo/eo_user.dict should not be deleted", eoUser.exists()) + } + + @Test + fun testExtractAssetsDictionaryUsesApkAssetPath() { + val assetBytes = "dictionary".toByteArray() + doReturn(java.io.ByteArrayInputStream(assetBytes)).`when`(mockAssets).open("dicts/main_bg.dict") + + val extracted = DictionaryInfoUtils.extractAssetsDictionary( + "main_bg.dict", + java.util.Locale.forLanguageTag("bg"), + context, + ) + + assertTrue("asset dictionary should be extracted", extracted?.readBytes()?.contentEquals(assetBytes) == true) + } + + @Test + fun testCheckVersionUpgradePreservesManuallyImportedAssets() { + // Stub mock assets to simulate that 'bg' has a main dictionary asset + doReturn(arrayOf("main_bg.dict")).`when`(mockAssets).list("dicts") + // The asset has length 10 + val mockStream = java.io.ByteArrayInputStream(ByteArray(10)) + doReturn(mockStream).`when`(mockAssets).open("dicts/main_bg.dict") + + val bgDir = File(DictionaryInfoUtils.getCacheDirectoryForLocale(java.util.Locale.forLanguageTag("bg"), context)!!) + bgDir.mkdirs() + // File has different length (20 vs 10) + val bgMain = File(bgDir, "main.dict").apply { + createNewFile() + writeBytes(ByteArray(20)) + } + + // Run checkVersionUpgrade + AppUpgrade.checkVersionUpgrade(context) + + // Verify: + // bg/main.dict should NOT be deleted because it has a different size + assertTrue("bg/main.dict should not be deleted since size is different", bgMain.exists()) + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/DictionaryFacilitatorAsyncTest.kt b/app/src/test/java/helium314/keyboard/latin/DictionaryFacilitatorAsyncTest.kt new file mode 100644 index 000000000..2006da1f5 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/DictionaryFacilitatorAsyncTest.kt @@ -0,0 +1,129 @@ +package helium314.keyboard.latin + +import android.content.Context +import com.android.inputmethod.keyboard.ProximityInfo +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.common.ComposedData +import helium314.keyboard.latin.common.InputPointers +import helium314.keyboard.latin.dictionary.Dictionary +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.settings.SettingsValues +import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.SuggestionResults +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DictionaryFacilitatorAsyncTest { + @Test + fun failedMainDictionaryLoadReleasesWaiters() { + val facilitator = DictionaryFacilitatorImpl() + val loadMethod = DictionaryFacilitatorImpl::class.java.declaredMethods.single { + it.name == "asyncReloadUninitializedMainDictionaries" + }.apply { isAccessible = true } + val latchField = DictionaryFacilitatorImpl::class.java + .getDeclaredField("mLatchForWaitingLoadingMainDictionaries") + .apply { isAccessible = true } + + Mockito.mockStatic(Settings::class.java).use { settings -> + settings.`when` { Settings.getValues() } + .thenThrow(IllegalStateException("forced dictionary-load failure")) + + loadMethod.invoke( + facilitator, + Mockito.mock(Context::class.java), + listOf(Locale.ENGLISH), + null, + ) + + val latch = latchField.get(facilitator) as CountDownLatch + assertTrue(latch.await(1, TimeUnit.SECONDS), "failed dictionary load must release waiters") + } + } + + @Test + fun failedSecondaryDictionarySuggestionDoesNotBlockPrimaryResults() { + Robolectric.setupService(LatinIME::class.java) + val facilitator = DictionaryFacilitatorImpl() + val primaryDictionary = Mockito.mock(Dictionary::class.java) + val secondaryDictionary = Mockito.mock(Dictionary::class.java) + stubSuggestions(primaryDictionary, arrayListOf()) + stubSuggestions(secondaryDictionary, IllegalStateException("forced secondary suggestion failure")) + + val dictionaryGroupClass = Class.forName("helium314.keyboard.latin.DictionaryGroup") + val constructor = dictionaryGroupClass.declaredConstructors.first { it.parameterCount == 4 } + .apply { isAccessible = true } + val primaryGroup = constructor.newInstance(Locale.ENGLISH, primaryDictionary, emptyMap(), null) + val secondaryGroup = constructor.newInstance(Locale.FRENCH, secondaryDictionary, emptyMap(), null) + DictionaryFacilitatorImpl::class.java.getDeclaredField("dictionaryGroups") + .apply { isAccessible = true } + .set(facilitator, listOf(primaryGroup, secondaryGroup)) + + val proximityInfo = Mockito.mock(ProximityInfo::class.java) + Mockito.`when`(proximityInfo.nativeProximityInfo).thenReturn(0L) + val keyboard = Mockito.mock(Keyboard::class.java) + Mockito.`when`(keyboard.proximityInfo).thenReturn(proximityInfo) + val executor = Executors.newSingleThreadExecutor() + val future = executor.submit { + facilitator.getSuggestionResults( + ComposedData(InputPointers(1), false, "test"), + NgramContext.EMPTY_PREV_WORDS_INFO, + keyboard, + SettingsValuesForSuggestion(false, false, "fallback"), + Suggest.SESSION_ID_TYPING, + SuggestedWords.INPUT_STYLE_TYPING, + ) + } + + try { + assertEquals(0, future.get(1, TimeUnit.SECONDS).size) + } finally { + future.cancel(true) + executor.shutdownNow() + } + } + + private fun stubSuggestions( + dictionary: Dictionary, + result: ArrayList, + ) { + Mockito.`when`( + dictionary.getSuggestions( + Mockito.any(ComposedData::class.java), + Mockito.any(NgramContext::class.java), + Mockito.anyLong(), + Mockito.any(SettingsValuesForSuggestion::class.java), + Mockito.anyInt(), + Mockito.anyFloat(), + Mockito.any(FloatArray::class.java), + ), + ).thenReturn(result) + } + + private fun stubSuggestions( + dictionary: Dictionary, + failure: RuntimeException, + ) { + Mockito.`when`( + dictionary.getSuggestions( + Mockito.any(ComposedData::class.java), + Mockito.any(NgramContext::class.java), + Mockito.anyLong(), + Mockito.any(SettingsValuesForSuggestion::class.java), + Mockito.anyInt(), + Mockito.anyFloat(), + Mockito.any(FloatArray::class.java), + ), + ).thenThrow(failure) + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index df2119ea0..dd81f1a6f 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -1615,6 +1615,10 @@ class InputLogicTest { // should be called before every test, so the same state is guaranteed private fun reset() { + // Drop messages left by asynchronous service setup or a previous scenario. + messages.clear() + delayedMessages.clear() + // reset input connection & facilitator currentScript = ScriptUtils.SCRIPT_LATIN text = "" diff --git a/docs/releasenote/release_notes_v3.9.6.md b/docs/releasenote/release_notes_v3.9.6.md new file mode 100644 index 000000000..17d065463 --- /dev/null +++ b/docs/releasenote/release_notes_v3.9.6.md @@ -0,0 +1,25 @@ +### 💖 Support Our Work +* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters! + +## 🚀 What's New + +### 🎨 Customization & Appearance +- **System Emoji Font Toggle**: Added a new setting under **Appearance** settings to use your system's custom emoji font (respects custom emoji modules like Magisk) in the app UI, settings, and search input fields instead of forcing the default compatibility emoji font. +- **App Language Preference**: Added a setting under **Preferences** to select LeanType's display language independent of the overall Android system language. + +### ⌨️ Layouts & Keyboard Behavior +- **Multi-Row Number Rows**: Added layout support for multi-row number row configurations. +- **Language Key Switch**: Fixed direct IME switch targeting when mapping actions to the language key. +- **Gesture Typing Default**: Gesture typing is now disabled by default. + +### ⚡ Performance & Spellchecking +- **Spellcheck Optimizations**: Improved spellcheck performance by adding cache lookups, personal dictionary validation, and optimizing regex matching. + +## 📦 Downloads (Choose Your Flavor) + +| File | Description | Permissions | +| :--- | :--- | :--- | +| **`1-LeanType_3.9.6-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet | +| **`1-LeanType_3.9.6-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet | +| **`2-LeanType_3.9.6-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet | +| **`3-LeanType_3.9.6-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet | diff --git a/docs/releasenote/release_notes_v3.9.7.md b/docs/releasenote/release_notes_v3.9.7.md new file mode 100644 index 000000000..76b8be067 --- /dev/null +++ b/docs/releasenote/release_notes_v3.9.7.md @@ -0,0 +1,30 @@ +### 💖 Support Our Work +* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters! + +## 🚀 What's New + +### 🛠️ Compatibility & Reproducible Builds +- **F-Droid Reproducible Build Fix**: Forced `android.enableR8.fullMode` to `false` in `settings.gradle` to resolve compiler output differences between build environments, ensuring consistent build hashes. + +### ⚡ Performance & Focus Latency +- **Android 17 Startup Optimization**: Resolved IME startup and focus latency on Android 17 by caching layouts and state settings, bypassing redundant settings reloads. + +### 🎨 User Interface & Styling +- **Themed Translation Bar Layout**: Styled button layouts for the horizontal language selector bar and aligned text colors with standard key themes for higher contrast. Fixed horizontal width constraint that pushed the close button off-screen. +- **Toolbar Swipe-to-Dismiss**: Added support for swiping to close/dismiss the toolbar. +- **Sorted Translation Target Languages**: Sorted the translation language selector dynamically to show last used target languages first. + +### 📖 Language & Corrective Dictionaries +- **Turkish Case-Folding Blacklist Fix**: Fixed Turkish word blacklist filtering by processing case-folding logic using the Turkish locale directly, correctly treating dotless `ı` and dotted `i` as independent characters. +- **Dictionary Upgrade & Protection**: Added support for in-app dictionary upgrades and protected user-downloaded dictionaries from accidental deletion. +- **Multilingual Settings Visibility**: Fixed the multilingual settings option to show when at least one secondary language/layout is enabled. +- **Immediate Download Status Refresh**: Ensured the dictionary installation status refreshes immediately after downloading from the missing dictionary dialog. + +## 📦 Downloads (Choose Your Flavor) + +| File | Description | Permissions | +| :--- | :--- | :--- | +| **`1-LeanType_3.9.7-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet | +| **`1-LeanType_3.9.7-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet | +| **`2-LeanType_3.9.7-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet | +| **`3-LeanType_3.9.7-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet | diff --git a/docs/releasenote/release_notes_v3.9.8.md b/docs/releasenote/release_notes_v3.9.8.md new file mode 100644 index 000000000..9e3b21622 --- /dev/null +++ b/docs/releasenote/release_notes_v3.9.8.md @@ -0,0 +1,28 @@ +### 💖 Support Our Work +* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters! + +## 🚀 What's New + +### 🛠️ Kotlin Gesture Engine Clean Up +- **Removed Experimental Kotlin Engine**: Fully removed the experimental Kotlin gesture typing engine (`SwipeGestureEngineKotlin.kt`), its settings (advanced toggle), its keycode (`GESTURE_DEEP_SEARCH`), and associated icons/resource strings. + +### 🎨 User Interface & Split Toolbar +- **Translation Selector Fix**: Fixed the target language list collapsing or showing only the close button in split/dual toolbar mode. +- **Top Toolbar Visibility**: Kept the top toolbar row fully visible when expanding the translation target language selector in split toolbar mode. + +### ⚙️ Database & Reliability +- **Restore SQLite DB Fix**: Fixed database restore lockup and write crash (`SQLITE_READONLY_DBMOVED`) by closing helpers and active Room connections before deleting the database. +- **Native Dictionary SIGSEGV Fix**: Prevented a native SIGSEGV crash during dictionary traversal by holding the read lock for the entire traversal duration. + +### ⚡ Welcome Wizard & Setup +- **Wizard Crash Fix**: Fixed a `ConcurrentModificationException` crash during step 3 of the Welcome Wizard when disabling/mutating enabled subtypes. +- **Default Gesture Engine**: Changed the default gesture typing engine to `"fallback"` (pure Java engine) consistently so that it works out of the box, rather than attempting to load `"native"` when not configured. + +## 📦 Downloads (Choose Your Flavor) + +| File | Description | Permissions | +| :--- | :--- | :--- | +| **`1-LeanType_3.9.8-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet | +| **`1-LeanType_3.9.8-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet | +| **`2-LeanType_3.9.8-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet | +| **`3-LeanType_3.9.8-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet | diff --git a/docs/releasenote/release_notes_v3.9.9.md b/docs/releasenote/release_notes_v3.9.9.md new file mode 100644 index 000000000..b11eb83e9 --- /dev/null +++ b/docs/releasenote/release_notes_v3.9.9.md @@ -0,0 +1,21 @@ +### 💖 Support Our Work +* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters! + +## 🚀 What's New + +### 🛠️ Crashes & ANR Fixes +- **Native JNI SIGABRT Fix**: Fixed a native crash in the LatinIME keyboard library (`libjni_latinime.so`) caused by thread-unsafe memory access during dictionary word iteration. +- **Gesture Indexer ANR Fix**: Prevented keyboard freezes and Application Not Responding (ANR) errors by running the fallback Java gesture indexer asynchronously on a background thread instead of blocking the main thread. +- **Asynchronous Dictionary Cleanup**: Moved dictionary closing and cleanup to a background coroutine to prevent the main thread from blocking on JNI write locks when switching languages. + +### 📖 Dictionary Preservation on Upgrade +- **Manual Import Preservation**: Stopped the app from deleting manually imported or replaced custom dictionaries during version upgrades. + +## 📦 Downloads (Choose Your Flavor) + +| File | Description | Permissions | +| :--- | :--- | :--- | +| **`1-LeanType_3.9.9-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet | +| **`1-LeanType_3.9.9-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet | +| **`2-LeanType_3.9.9-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet | +| **`3-LeanType_3.9.9-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet | diff --git a/settings.gradle b/settings.gradle index 2a01f40c8..52b4fc4e6 100755 --- a/settings.gradle +++ b/settings.gradle @@ -1,11 +1,9 @@ include ':app' include ':tools:make-emoji-keys' -// Dynamically enable R8 fullMode for non-reproducible optimised flavor builds to unlock maximum compilation optimizations. +// Explicitly disable R8 Full Mode programmatically to guarantee reproducible builds gradle.projectsLoaded { gradle -> - if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("optimised") || it.toLowerCase().contains("optimized") }) { - gradle.rootProject.allprojects { project -> - project.ext.set("android.enableR8.fullMode", "true") - } + gradle.rootProject.allprojects { project -> + project.ext.set("android.enableR8.fullMode", "false") } }