From a332c0d464fde51f9c3c91ae8317c9ca4de7d8a5 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 20 Jul 2026 15:27:20 +0200 Subject: [PATCH 1/4] fix(android): show updates for lexical models With this change the model info activity will show an available update to the lexical model. Commit partially drafted by AI (Gemini 3 Flash Preview). Fixes: #16226 Build-bot: release:android --- .../kmapro/LanguageSettingsActivity.java | 19 ++-- .../com/tavultesoft/kmapro/MainActivity.java | 2 +- .../keyman/engine/KeyboardPickerActivity.java | 30 +++++- .../keyman/engine/ModelPickerActivity.java | 8 +- .../engine/cloud/CloudDataJsonUtil.java | 52 +++++----- .../impl/CloudCatalogDownloadCallback.java | 1 + .../keyman/engine/data/CloudRepository.java | 57 ++++++++++- .../engine/data/CloudRepositoryTests.java | 98 +++++++++++++++++++ 8 files changed, 229 insertions(+), 38 deletions(-) create mode 100644 android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java index 9342a0b5285..97dee3c7fc8 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java @@ -159,14 +159,19 @@ public void onCheckedChanged(RadioGroup group, @IdRes int checkId) { @Override public void onClick(View v) { // Start ModelPickerActivity + Intent intent = new Intent(context, ModelPickerActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); Bundle bundle = new Bundle(); - bundle.putString(KMManager.KMKey_LanguageID, lgCode); - bundle.putString(KMManager.KMKey_LanguageName, lgName); - bundle.putString(KMManager.KMKey_CustomHelpLink, customHelpLink); - Intent i = new Intent(context, ModelPickerActivity.class); - i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - i.putExtras(bundle); - startActivity(i); + HashMap lmInfo = KMManager.getAssociatedLexicalModel(lgCode); + if (lmInfo != null) { + bundle.putSerializable(KMManager.KMKey_LexicalModel, lmInfo); + } else { + bundle.putString(KMManager.KMKey_LanguageID, lgCode); + bundle.putString(KMManager.KMKey_LanguageName, lgName); + bundle.putString(KMManager.KMKey_CustomHelpLink, customHelpLink); + } + intent.putExtras(bundle); + startActivity(intent); } }); diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java index 89ad50d0466..01f38a4a66a 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java @@ -468,7 +468,7 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (item.getItemId() == R.id.action_update_keyboards) { KMManager.getUpdateTool().executeOpenUpdates(); - // Dismiss icon + // Dismiss icon - we intentionally hide the icon, even if the user cancels the update updateUpdateCountIndicator(0); final MenuItem _keyboardupdate = menu.findItem(R.id.action_update_keyboards); if (_keyboardupdate != null && _keyboardupdate.isVisible()) { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java index 29cd0e61dc9..283b858fd43 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java @@ -263,14 +263,14 @@ protected void onResume() { // Determine the index to the current keyboard position to highlight as the selected keyboard - int currentKeyboardIndex; + int currentKeyboardIndex; String currentKeyboardKey = KMKeyboard.currentKeyboard(); if (currentKeyboardKey == null) { SharedPreferences prefs = this.getSharedPreferences(this.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); currentKeyboardIndex = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); } else { currentKeyboardIndex = KeyboardController.getInstance().getKeyboardIndex(currentKeyboardKey); - } + } setSelection(currentKeyboardIndex); imeList = getIMEList(this); @@ -756,6 +756,32 @@ public static int getLexicalModelIndex(Context context, String lexicalModelKey) return index; } + /** + * Get an array list with all lexical model hashmaps matching the lexical model ID. + * @param context + * @param lexicalModelId - ID of the lexical model + * @return Array of lexical model hashmaps with lexicalModelId. Otherwise empty. + */ + public static ArrayList> getLexicalModelsMatchingId(Context context, String lexicalModelId) { + ArrayList> lexicalModels = new ArrayList<>(); + + if (lexicalModelsList == null) { + lexicalModelsList = getLexicalModelsList(context); + } + + if (lexicalModelsList != null) { + int length = lexicalModelsList.size(); + for (int i=0; i < length; i++) { + HashMap lmInfo = lexicalModelsList.get(i); + if (lexicalModelId.equals(lmInfo.get(KMManager.KMKey_LexicalModelID))) { + lexicalModels.add(lmInfo); + } + } + } + + return lexicalModels; + } + public static HashMap getLexicalModelInfo(Context context,int index) { if (index < 0) { return null; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java b/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java index 4595aae1e70..3451b8ec830 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java @@ -19,6 +19,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; +import androidx.core.os.BundleCompat; import com.keyman.engine.cloud.CloudApiTypes; import com.keyman.engine.cloud.CloudDownloadMgr; @@ -69,14 +70,15 @@ public void onCreate(Bundle savedInstanceState) { TextView textView = (TextView) findViewById(R.id.bar_title); Bundle bundle = getIntent().getExtras(); - String newLanguageID = bundle.getString(KMManager.KMKey_LanguageID); - String newCustomHelpLink = bundle.getString(KMManager.KMKey_CustomHelpLink, ""); + HashMap lmInfo = BundleCompat.getSerializable(bundle, KMManager.KMKey_LexicalModel, HashMap.class); + final String newLanguageID = lmInfo != null ? lmInfo.get(KMManager.KMKey_LanguageID) : bundle.getString(KMManager.KMKey_LanguageID, ""); + final String newCustomHelpLink = lmInfo != null ? lmInfo.get(KMManager.KMKey_CustomHelpLink) : bundle.getString(KMManager.KMKey_CustomHelpLink, ""); + final String languageName = lmInfo != null ? lmInfo.get(KMManager.KMKey_LanguageName) : bundle.getString(KMManager.KMKey_LanguageName, ""); // Sometimes we need to re-initialize the list of models that are displayed in the ListView languageID = newLanguageID; customHelpLink = newCustomHelpLink; - final String languageName = bundle.getString(KMManager.KMKey_LanguageName); textView.setText(String.format(getString(R.string.model_picker_header), languageName)); listView = (ListView) findViewById(R.id.listView); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java index 46eb51f5586..b44ad509cb3 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java @@ -11,6 +11,7 @@ import com.keyman.engine.KeyboardPickerActivity; import com.keyman.engine.R; import com.keyman.engine.cloud.CloudApiTypes; +import com.keyman.engine.data.CloudRepository; import com.keyman.engine.data.Keyboard; import com.keyman.engine.data.KeyboardController; import com.keyman.engine.data.LexicalModel; @@ -188,7 +189,6 @@ public static void processKeyboardPackageUpdateJSON(Context aContext, JSONObject } public static void processLexicalModelPackageUpdateJSON(Context aContext, JSONObject pkgData, List updateBundles) { - boolean saveModelsList = false; // Parse for lexical model package updates if (pkgData.has(CDKey_Models)) { try { @@ -201,18 +201,11 @@ public static void processLexicalModelPackageUpdateJSON(Context aContext, JSONOb String cloudVersion = cloudModelObj.getString(CDKey_Version); String cloudKMP = cloudModelObj.getString(CDKey_KMP); // Valid lexical model package exists. See if lexical model list needs to be updated - // Valid keyboard package exists. See if keyboard list needs to be updated - int index = KeyboardPickerActivity.getLexicalModelIndex(aContext, lexicalModelID); - if (index != -1) { - HashMap lmInfo = KeyboardPickerActivity.getLexicalModelInfo(aContext, index); - String version = lmInfo.get(KMManager.KMKey_Version); + ArrayList> lmInfos = KeyboardPickerActivity.getLexicalModelsMatchingId(aContext, lexicalModelID); + for (HashMap lmInfo : lmInfos) { + String version = lmInfo.get(KMManager.KMKey_LexicalModelVersion); if (lexicalModelID.equalsIgnoreCase(lmInfo.get(KMManager.KMKey_LexicalModelID)) && - (FileUtils.compareVersions(cloudVersion, version) == FileUtils.VERSION_GREATER) && - (!MapCompat.getOrDefault(lmInfo, KMManager.KMKey_KMPLink, "").equalsIgnoreCase(cloudKMP))) { - // Update keyboard with the latest KMP link - lmInfo.put(KMManager.KMKey_KMPLink, cloudKMP); - KeyboardPickerActivity.addLexicalModel(aContext, lmInfo); - + (FileUtils.compareVersions(cloudVersion, version) == FileUtils.VERSION_GREATER)) { // Update bundle list LexicalModel lm = new LexicalModel( lmInfo.get(KMManager.KMKey_PackageID), @@ -220,13 +213,29 @@ public static void processLexicalModelPackageUpdateJSON(Context aContext, JSONOb lmInfo.get(KMManager.KMKey_LexicalModelName), lmInfo.get(KMManager.KMKey_LanguageID), lmInfo.get(KMManager.KMKey_LanguageName), - lmInfo.get(KMManager.KMKey_Version), + lmInfo.get(KMManager.KMKey_LexicalModelVersion), lmInfo.get(KMManager.KMKey_CustomHelpLink), lmInfo.get(KMManager.KMKey_KMPLink)); - Bundle bundle = new Bundle(lm.buildDownloadBundle()); - updateBundles.add(bundle); + String updateKMP = lm.getUpdateKMP(); + if (cloudLinkIsNewer(updateKMP, cloudKMP)) { + // Update lexical model info with the latest KMP link after appending languageID + String languageID = lm.getLanguageID(); + String link = String.format("%s&bcp47=%s", cloudKMP, languageID); + lmInfo.put(KMManager.KMKey_KMPLink, link); + KeyboardPickerActivity.addLexicalModel(aContext, lmInfo); + lm.setUpdateKMP(link); + updateKMP = link; + LexicalModel cached = CloudRepository.shared.getLexicalModel(aContext, languageID, lexicalModelID); + if (cached != null) { + cached.setUpdateKMP(link); + } + } - saveModelsList = true; + if (updateKMP != null && !updateKMP.isEmpty()) { + // Update bundle list for update notifications + Bundle bundle = new Bundle(lm.buildDownloadBundle()); + updateBundles.add(bundle); + } } } } @@ -367,14 +376,13 @@ public static boolean cloudLinkIsNewer(String updateKMP, String cloudKMP) { return false; } - try { - Uri cloudLink = Uri.parse(cloudKMP); - - if (updateKMP == null || updateKMP.isEmpty()) { - return true; - } + if (updateKMP == null || updateKMP.isEmpty()) { + return true; + } + try { Uri localLink = Uri.parse(updateKMP); + Uri cloudLink = Uri.parse(cloudKMP); boolean pathsMatch = localLink.getLastPathSegment().equalsIgnoreCase(cloudLink.getLastPathSegment()); boolean cloudVersionNewer = FileUtils.compareVersions( diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/impl/CloudCatalogDownloadCallback.java b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/impl/CloudCatalogDownloadCallback.java index 21769f76ddb..a8fe4cc3e5b 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/impl/CloudCatalogDownloadCallback.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/impl/CloudCatalogDownloadCallback.java @@ -154,6 +154,7 @@ public void processCloudReturns(Dataset aDataSet, CloudCatalogDownloadReturns js // The actual update check CloudDataJsonUtil.processKeyboardPackageUpdateJSON(context, jsonTuple.packagesJSON, updateBundles); + CloudDataJsonUtil.processLexicalModelPackageUpdateJSON(context, jsonTuple.packagesJSON, updateBundles); // Only add installed kmp keyboards aDataSet.keyboards.clear(); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java index 5bd4d8d6849..e3d28a725ab 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java @@ -21,6 +21,7 @@ import com.keyman.engine.packages.JSONUtils; import com.keyman.engine.util.BCP47; import com.keyman.engine.util.DownloadFileUtils; +import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.VersionUtils; @@ -158,10 +159,23 @@ private boolean shouldUseCache(Context context, File cacheFile) { * @return LexicalModel of an associated lexical model. Null if no match found */ public LexicalModel getAssociatedLexicalModel(@NonNull Context context, String languageID) { + return getLexicalModel(context, languageID, null); + } + + /** + * Get the lexical model with modelID associated with the given language ID. If + * modelID is null, return the first model associated with languageID. Available + * models are from the cloud catalog and locally installed models. + * @param context Context + * @param languageID String of the language ID to search + * @param modelID String of the lexical model ID. + * @return LexicalModel of an associated lexical model. Null if no match found. + */ + public LexicalModel getLexicalModel(@NonNull Context context, String languageID, String modelID) { if (memCachedDataset != null) { for (int i=0; i < memCachedDataset.lexicalModels.getCount(); i++) { LexicalModel lm = memCachedDataset.lexicalModels.getItem(i); - if (BCP47.languageEquals(lm.getLanguageID(), languageID)) { + if (BCP47.languageEquals(lm.getLanguageID(), languageID) && (modelID == null || modelID.equals(lm.getLexicalModelID()))) { return lm; } } @@ -253,6 +267,41 @@ public void updateDatasetIfNeeded(@NonNull Context context, UpdateHandler update downloadMetaDataFromServer(context,updateHandler,onSuccess,onFailure); } + private void mergeLexicalModels(Dataset.LexicalModels datasetModels, List newModels) { + if (newModels == null) { + return; + } + + List existingModels = new ArrayList<>(datasetModels.asList()); + List mergedModels = new ArrayList<>(); + + // Process all incoming models + for (LexicalModel newModel : newModels) { + LexicalModel existingMatch = null; + for (int i = 0; i < existingModels.size(); i++) { + if (newModel.equals(existingModels.get(i))) { + existingMatch = existingModels.remove(i); + break; + } + } + + if (existingMatch != null) { + if (FileUtils.compareVersions(existingMatch.getVersion(), newModel.getVersion()) != FileUtils.VERSION_LOWER) { + mergedModels.add(existingMatch); + } else { + mergedModels.add(newModel); + } + } else { + mergedModels.add(newModel); + } + } + + // Add remaining existing models that weren't matched + mergedModels.addAll(existingModels); + + datasetModels.clear(); + datasetModels.addAll(mergedModels); + } /** * precache dataset and notify callbacks if no update from cloud api services is necessary. @@ -286,11 +335,13 @@ private void preCacheDataSet(@NonNull Context context, UpdateHandler updateHandl languageCodes.add(installedSet.getItem(i).code); } + // add all models from installed set + memCachedDataset.lexicalModels.addAll(installedSet.lexicalModels.asList()); + // Get kmp.json info from installed (adhoc and cloud) models. // Consolidate kmp.json info from packages/ JSONObject kmpLanguagesArray = wrapKmpKeyboardJSON(JSONUtils.getLanguages()); JSONArray kmpLexicalModelsArray = JSONUtils.getLexicalModels(); - final boolean fromKMP = true; try { if (kmpLanguagesArray.getJSONObject(KMKeyboardDownloaderActivity.KMKey_Languages). @@ -298,7 +349,7 @@ private void preCacheDataSet(@NonNull Context context, UpdateHandler updateHandl memCachedDataset.keyboards.addAll(CloudDataJsonUtil.processKeyboardJSON(kmpLanguagesArray, true)); } if (kmpLexicalModelsArray.length() > 0) { - memCachedDataset.lexicalModels.addAll(CloudDataJsonUtil.processLexicalModelJSON(kmpLexicalModelsArray, fromKMP)); + mergeLexicalModels(memCachedDataset.lexicalModels, CloudDataJsonUtil.processLexicalModelJSON(kmpLexicalModelsArray, true)); } } catch (Exception e) { KMLog.LogException(TAG, "preCacheDataSet error ", e); diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java b/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java new file mode 100644 index 00000000000..e0ac4dfad19 --- /dev/null +++ b/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java @@ -0,0 +1,98 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +package com.keyman.engine.data; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class CloudRepositoryTests { + + private CloudRepository repository; + private Dataset dataset; + private Context context; + + private boolean containsModel(Dataset.LexicalModels models, String modelID) { + for (int i = 0; i < models.getCount(); i++) { + if (models.getItem(i).getLexicalModelID().equals(modelID)) { + return true; + } + } + return false; + } + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + repository = CloudRepository.shared; + dataset = new Dataset(context); + } + + @Test + public void testMergeLexicalModels_PreservesUniqueExistingModels() throws Exception { + // Setup + dataset.lexicalModels.add(new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.0", "", "")); + List newModels = new ArrayList<>(); + newModels.add(new LexicalModel("pkg2", "model2", "Model 2", "fr", "French", "1.0", "", "")); + + // Execute + Method mergeMethod = CloudRepository.class.getDeclaredMethod("mergeLexicalModels", Dataset.LexicalModels.class, List.class); + mergeMethod.setAccessible(true); + mergeMethod.invoke(repository, dataset.lexicalModels, newModels); + + // Verify + Assert.assertEquals(2, dataset.lexicalModels.getCount()); + Assert.assertTrue(containsModel(dataset.lexicalModels, "model1")); + Assert.assertTrue(containsModel(dataset.lexicalModels, "model2")); + } + + @Test + public void testMergeLexicalModels_UpdatesVersion() throws Exception { + // Setup + dataset.lexicalModels.add(new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.0", "", "")); + + List newModels = new ArrayList<>(); + // New model (same ID, newer version v1.1) + newModels.add(new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.1", "", "")); + + // Execute + Method mergeMethod = CloudRepository.class.getDeclaredMethod("mergeLexicalModels", Dataset.LexicalModels.class, List.class); + mergeMethod.setAccessible(true); + mergeMethod.invoke(repository, dataset.lexicalModels, newModels); + + // Verify + Assert.assertEquals(1, dataset.lexicalModels.getCount()); + LexicalModel result = dataset.lexicalModels.getItem(0); + Assert.assertEquals("1.1", result.getVersion()); + } + + @Test + public void testMergeLexicalModels_KeepsOlderIfNewerIsLowerVersion() throws Exception { + // Setup + dataset.lexicalModels.add(new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.2", "", "")); + + List newModels = new ArrayList<>(); + // New model (same ID, older version v1.1) + newModels.add(new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.1", "", "")); + + // Execute + Method mergeMethod = CloudRepository.class.getDeclaredMethod("mergeLexicalModels", Dataset.LexicalModels.class, List.class); + mergeMethod.setAccessible(true); + mergeMethod.invoke(repository, dataset.lexicalModels, newModels); + + // Verify + Assert.assertEquals(1, dataset.lexicalModels.getCount()); + LexicalModel result = dataset.lexicalModels.getItem(0); + Assert.assertEquals("1.2", result.getVersion()); + } +} From e3ed30a3091c67101ac404a1c55fa27dd28cdcca Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 5 Aug 2026 18:04:48 +0200 Subject: [PATCH 2/4] chore(android): revert unnecessary parts of previous commit --- .../kmapro/LanguageSettingsActivity.java | 19 +++++++------------ .../keyman/engine/ModelPickerActivity.java | 8 +++----- .../engine/cloud/CloudDataJsonUtil.java | 4 ---- .../keyman/engine/data/CloudRepository.java | 15 +-------------- 4 files changed, 11 insertions(+), 35 deletions(-) diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java index 97dee3c7fc8..9342a0b5285 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/LanguageSettingsActivity.java @@ -159,19 +159,14 @@ public void onCheckedChanged(RadioGroup group, @IdRes int checkId) { @Override public void onClick(View v) { // Start ModelPickerActivity - Intent intent = new Intent(context, ModelPickerActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); Bundle bundle = new Bundle(); - HashMap lmInfo = KMManager.getAssociatedLexicalModel(lgCode); - if (lmInfo != null) { - bundle.putSerializable(KMManager.KMKey_LexicalModel, lmInfo); - } else { - bundle.putString(KMManager.KMKey_LanguageID, lgCode); - bundle.putString(KMManager.KMKey_LanguageName, lgName); - bundle.putString(KMManager.KMKey_CustomHelpLink, customHelpLink); - } - intent.putExtras(bundle); - startActivity(intent); + bundle.putString(KMManager.KMKey_LanguageID, lgCode); + bundle.putString(KMManager.KMKey_LanguageName, lgName); + bundle.putString(KMManager.KMKey_CustomHelpLink, customHelpLink); + Intent i = new Intent(context, ModelPickerActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); + i.putExtras(bundle); + startActivity(i); } }); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java b/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java index 3451b8ec830..4595aae1e70 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/ModelPickerActivity.java @@ -19,7 +19,6 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; -import androidx.core.os.BundleCompat; import com.keyman.engine.cloud.CloudApiTypes; import com.keyman.engine.cloud.CloudDownloadMgr; @@ -70,15 +69,14 @@ public void onCreate(Bundle savedInstanceState) { TextView textView = (TextView) findViewById(R.id.bar_title); Bundle bundle = getIntent().getExtras(); - HashMap lmInfo = BundleCompat.getSerializable(bundle, KMManager.KMKey_LexicalModel, HashMap.class); - final String newLanguageID = lmInfo != null ? lmInfo.get(KMManager.KMKey_LanguageID) : bundle.getString(KMManager.KMKey_LanguageID, ""); - final String newCustomHelpLink = lmInfo != null ? lmInfo.get(KMManager.KMKey_CustomHelpLink) : bundle.getString(KMManager.KMKey_CustomHelpLink, ""); - final String languageName = lmInfo != null ? lmInfo.get(KMManager.KMKey_LanguageName) : bundle.getString(KMManager.KMKey_LanguageName, ""); + String newLanguageID = bundle.getString(KMManager.KMKey_LanguageID); + String newCustomHelpLink = bundle.getString(KMManager.KMKey_CustomHelpLink, ""); // Sometimes we need to re-initialize the list of models that are displayed in the ListView languageID = newLanguageID; customHelpLink = newCustomHelpLink; + final String languageName = bundle.getString(KMManager.KMKey_LanguageName); textView.setText(String.format(getString(R.string.model_picker_header), languageName)); listView = (ListView) findViewById(R.id.listView); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java index b44ad509cb3..a29d937a833 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/cloud/CloudDataJsonUtil.java @@ -225,10 +225,6 @@ public static void processLexicalModelPackageUpdateJSON(Context aContext, JSONOb KeyboardPickerActivity.addLexicalModel(aContext, lmInfo); lm.setUpdateKMP(link); updateKMP = link; - LexicalModel cached = CloudRepository.shared.getLexicalModel(aContext, languageID, lexicalModelID); - if (cached != null) { - cached.setUpdateKMP(link); - } } if (updateKMP != null && !updateKMP.isEmpty()) { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java index e3d28a725ab..99e158de3fa 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java @@ -159,23 +159,10 @@ private boolean shouldUseCache(Context context, File cacheFile) { * @return LexicalModel of an associated lexical model. Null if no match found */ public LexicalModel getAssociatedLexicalModel(@NonNull Context context, String languageID) { - return getLexicalModel(context, languageID, null); - } - - /** - * Get the lexical model with modelID associated with the given language ID. If - * modelID is null, return the first model associated with languageID. Available - * models are from the cloud catalog and locally installed models. - * @param context Context - * @param languageID String of the language ID to search - * @param modelID String of the lexical model ID. - * @return LexicalModel of an associated lexical model. Null if no match found. - */ - public LexicalModel getLexicalModel(@NonNull Context context, String languageID, String modelID) { if (memCachedDataset != null) { for (int i=0; i < memCachedDataset.lexicalModels.getCount(); i++) { LexicalModel lm = memCachedDataset.lexicalModels.getItem(i); - if (BCP47.languageEquals(lm.getLanguageID(), languageID) && (modelID == null || modelID.equals(lm.getLexicalModelID()))) { + if (BCP47.languageEquals(lm.getLanguageID(), languageID)) { return lm; } } From 0368a7bc5609c91ef6c2ef1c1aa288cf0deb0351 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 5 Aug 2026 15:56:28 +0200 Subject: [PATCH 3/4] test(android): add unit tests for `getAssociatedLexicalModel` --- .../engine/data/CloudRepositoryTests.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java b/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java index e0ac4dfad19..b1796764be1 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/data/CloudRepositoryTests.java @@ -5,12 +5,14 @@ import android.content.Context; import androidx.test.core.app.ApplicationProvider; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; @@ -31,6 +33,12 @@ private boolean containsModel(Dataset.LexicalModels models, String modelID) { return false; } + private void setMemCachedDataset(Dataset dataset) throws Exception { + Field field = CloudRepository.class.getDeclaredField("memCachedDataset"); + field.setAccessible(true); + field.set(repository, dataset); + } + @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); @@ -38,6 +46,12 @@ public void setUp() { dataset = new Dataset(context); } + @After + public void tearDown() throws Exception { + // Reset singleton state + setMemCachedDataset(null); + } + @Test public void testMergeLexicalModels_PreservesUniqueExistingModels() throws Exception { // Setup @@ -95,4 +109,41 @@ public void testMergeLexicalModels_KeepsOlderIfNewerIsLowerVersion() throws Exce LexicalModel result = dataset.lexicalModels.getItem(0); Assert.assertEquals("1.2", result.getVersion()); } + + @Test + public void testGetAssociatedLexicalModel_ReturnsCorrectModel() throws Exception { + // Setup + LexicalModel model1 = new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.0", "", ""); + LexicalModel model2 = new LexicalModel("pkg2", "model2", "Model 2", "fr", "French", "1.0", "", ""); + dataset.lexicalModels.add(model1); + dataset.lexicalModels.add(model2); + setMemCachedDataset(dataset); + + // Execute & Verify + Assert.assertEquals(model1, repository.getAssociatedLexicalModel(context, "en")); + Assert.assertEquals(model2, repository.getAssociatedLexicalModel(context, "fr")); + Assert.assertEquals(model1, repository.getAssociatedLexicalModel(context, "EN")); // Case insensitivity + } + + @Test + public void testGetAssociatedLexicalModel_ReturnsNullWhenDatasetIsNull() throws Exception { + // Setup + setMemCachedDataset(null); + + // Execute & Verify + Assert.assertNull(repository.getAssociatedLexicalModel(context, "en")); + } + + @Test + public void testGetAssociatedLexicalModel_ReturnsFirstMatch() throws Exception { + // Setup + LexicalModel model1 = new LexicalModel("pkg1", "model1", "Model 1", "en", "English", "1.0", "", ""); + LexicalModel model2 = new LexicalModel("pkg2", "model2", "Model 2", "en", "English", "1.0", "", ""); + dataset.lexicalModels.add(model1); + dataset.lexicalModels.add(model2); + setMemCachedDataset(dataset); + + // Execute & Verify + Assert.assertEquals(model1, repository.getAssociatedLexicalModel(context, "en")); + } } From 6e83adbcf8045e8274660fae99e389c36a4acd5b Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 5 Aug 2026 17:55:03 +0200 Subject: [PATCH 4/4] docs(android): update and improve comments --- .../com/keyman/engine/data/CloudRepository.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java index 99e158de3fa..650f0ebd0a2 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/CloudRepository.java @@ -254,6 +254,14 @@ public void updateDatasetIfNeeded(@NonNull Context context, UpdateHandler update downloadMetaDataFromServer(context,updateHandler,onSuccess,onFailure); } + /** + * Merges the new lexical models into the existing dataset models. If + * a model already exists, it compares the versions and uses the newer one. + * Otherwise the model gets added to the dataset. Models that exist + * in the dataset but not in the new models list are preserved. + * @param datasetModels Collection of lexical models in the dataset + * @param newModels List of new lexical models to merge + */ private void mergeLexicalModels(Dataset.LexicalModels datasetModels, List newModels) { if (newModels == null) { return; @@ -322,7 +330,7 @@ private void preCacheDataSet(@NonNull Context context, UpdateHandler updateHandl languageCodes.add(installedSet.getItem(i).code); } - // add all models from installed set + // add all models that are installed locally memCachedDataset.lexicalModels.addAll(installedSet.lexicalModels.asList()); // Get kmp.json info from installed (adhoc and cloud) models. @@ -336,6 +344,9 @@ private void preCacheDataSet(@NonNull Context context, UpdateHandler updateHandl memCachedDataset.keyboards.addAll(CloudDataJsonUtil.processKeyboardJSON(kmpLanguagesArray, true)); } if (kmpLexicalModelsArray.length() > 0) { + // Add all models that exist locally, whether or not they are installed. + // This set of models doesn't have the download url set, so instead of + // replacing the models in `memCacheDataSet.lexicalModels` we merge them. mergeLexicalModels(memCachedDataset.lexicalModels, CloudDataJsonUtil.processLexicalModelJSON(kmpLexicalModelsArray, true)); } } catch (Exception e) {