From 49e222c273315a3d0a5859b464e1fbb5360d9638 Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Mon, 18 May 2026 21:07:30 +1000 Subject: [PATCH 01/21] fix(windows): actions list copy constructor --- core/src/mock/mock_processor.cpp | 11 +++++++- core/src/state.cpp | 19 +++++++++++++ core/src/state.hpp | 4 +++ core/tests/unit/kmnkbd/state_api.tests.cpp | 32 ++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/core/src/mock/mock_processor.cpp b/core/src/mock/mock_processor.cpp index 570553264d..d014716c87 100644 --- a/core/src/mock/mock_processor.cpp +++ b/core/src/mock/mock_processor.cpp @@ -125,8 +125,17 @@ namespace km { { assert(state); assert(action_item); - if ((!state) || (!action_item)) + if ((!state) || (!action_item)){ return false; + } + // For this mock processor we only support queuing PERSIST_OPT action items. + if (action_item->type == KM_CORE_IT_PERSIST_OPT && action_item->option) { + state->actions().push_persist(update_option(static_cast(action_item->option->scope), action_item->option->key, action_item->option->value)); + return true; + } + else { + return false; + } return false; } diff --git a/core/src/state.cpp b/core/src/state.cpp index 8eb99d1b0a..c354d3434b 100644 --- a/core/src/state.cpp +++ b/core/src/state.cpp @@ -36,6 +36,25 @@ void actions::push_capslock(bool turnOn) { emplace_back(std::move(ai)); } +actions::actions(actions const &other) +: std::vector(other) +, _option_items_stack(other._option_items_stack) +{ + // Update all option pointers to point to the new stack + for (auto &item : *this) { + if (item.type == KM_CORE_IT_PERSIST_OPT && item.option) { + // Find the corresponding option in the new stack + // The pointers in the original point to positions in other._option_items_stack + // We need to find the equivalent position in our _option_items_stack + auto original_ptr = item.option; + auto original_base = reinterpret_cast(other._option_items_stack.data()); + auto offset = original_ptr - original_base; + if (offset >= 0 && static_cast(offset) < _option_items_stack.size()) { + item.option = &_option_items_stack[offset]; + } + } + } +} state::state(km::core::abstract_processor & ap, km_core_option_item const *env) : _processor(ap) diff --git a/core/src/state.hpp b/core/src/state.hpp index da2cf6bb75..098aad93da 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -35,6 +35,10 @@ class actions : public std::vector public: template actions(Args&&... args); + actions(actions const &other); + // If the operator is needed in the future, it shall be implemented + // in like the deep copy constructor. Blocking accidental use of it. + actions &operator=(actions const &) = delete; void push_character(km_core_usv usv); void push_marker(uint32_t marker); diff --git a/core/tests/unit/kmnkbd/state_api.tests.cpp b/core/tests/unit/kmnkbd/state_api.tests.cpp index 9b46e2fea8..8def80f967 100644 --- a/core/tests/unit/kmnkbd/state_api.tests.cpp +++ b/core/tests/unit/kmnkbd/state_api.tests.cpp @@ -251,6 +251,12 @@ constexpr km_core_option_item const expected_persist_opt = { KM_CORE_OPT_KEYBOARD }; +//constexpr km_core_option_item const clone_persist_opt = { +// u"__test_clone", +// u"Not in original", +// KM_CORE_OPT_KEYBOARD +//}; + extern "C" { uint8_t test_imx_callback(km_core_state *state, uint32_t imx_id, void *callback_object){ @@ -270,6 +276,7 @@ int main(int argc, char * argv[]) km_core_keyboard * test_kb = nullptr; km_core_state * test_state = nullptr, * test_clone = nullptr; +// * test_clone2 = nullptr; test_kb = (km_core_keyboard *)new km::core::mock_processor(km::core::path("dummy.mock")); // Simple sanity tests. @@ -393,9 +400,34 @@ int main(int argc, char * argv[]) clone_state_deleted_text )); +// Need to test the option pointer is pointing to the correct the correct _option_items_stack. +// This could not be tested like this as the way commit() works adding KM_CORE_IT_END to the +// end of the actions list. This means when calling km_core_state_queue_action_items +// I get an assert in push_persist "empty() || back().type != KM_CORE_IT_END" it is a protection +// against adding a action to an already commited list. +// To test this change I need to do mocking closer to the integration of that actions list. + + + +// km_core_action_item actions_test[] = { +// { KM_CORE_IT_PERSIST_OPT, {0,}, }, +// { KM_CORE_IT_END, {0,}, } +// }; + +// actions_test[0].option = &clone_persist_opt; + +//try_status(km_core_state_create(test_kb, test_env_opts, &test_clone2)); + + +//km_core_state_queue_action_items(test_clone2, actions_test); +//test_assert(action_items(test_clone2, actions_test)); + +//test_assert(action_items(test_clone2, {action_clone, {KM_CORE_IT_END}})); + // Destroy them km_core_state_dispose(test_state); km_core_state_dispose(test_clone); + //km_core_state_dispose(test_clone2); km_core_keyboard_dispose(test_kb); return 0; From 2684aca482b4edfbba1d9764abe0a3a3447f4523 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 20 Jul 2026 13:01:45 -0500 Subject: [PATCH 02/21] auto: increment master version to 19.0.260 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 399e3077f2..b30edcedba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.259 alpha 2026-07-20 + +* docs(web): fix typos following #16170 (#16225) + ## 19.0.258 alpha 2026-07-15 * chore(android): remove doc for non-existing deprecated functions (#16244) diff --git a/VERSION.md b/VERSION.md index 48c4023fd8..70e182160e 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.259 \ No newline at end of file +19.0.260 \ No newline at end of file From a332c0d464fde51f9c3c91ae8317c9ca4de7d8a5 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 20 Jul 2026 15:27:20 +0200 Subject: [PATCH 03/21] 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 9342a0b528..97dee3c7fc 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 final class LanguageSettingsActivity extends BaseActivity { @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 89ad50d046..01f38a4a66 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 class MainActivity extends BaseActivity implements OnKeyboardEventListene 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 29cd0e61dc..283b858fd4 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 @@ public final class KeyboardPickerActivity extends BaseActivity { // 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 final class KeyboardPickerActivity extends BaseActivity { 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 4595aae1e7..3451b8ec83 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 android.widget.Toast; 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 final class ModelPickerActivity extends BaseActivity { 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 46eb51f558..b44ad509cb 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.KMManager; 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 class CloudDataJsonUtil { } 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 class CloudDataJsonUtil { 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 class CloudDataJsonUtil { 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 class CloudDataJsonUtil { return false; } + if (updateKMP == null || updateKMP.isEmpty()) { + return true; + } + try { - Uri cloudLink = Uri.parse(cloudKMP); - - if (updateKMP == null || updateKMP.isEmpty()) { - return true; - } - 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 21769f76dd..a8fe4cc3e5 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 class CloudCatalogDownloadCallback implements ICloudDownloadCallback 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 @@ public class CloudRepository { 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 @@ public class CloudRepository { 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 0000000000..e0ac4dfad1 --- /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 a07fff8be3e30fc39aa40ed75c17fd445f9e8101 Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:52:32 +1000 Subject: [PATCH 04/21] fix(windows): change option_stack to deque when vectors are resized the memory is realocated so all the option pointers become invalid. Using a deque avoids this it has all the same methods so it is straight replacement. fixes: #15961 --- core/src/mock/mock_processor.cpp | 16 ++++- core/src/state.cpp | 19 +++--- core/src/state.hpp | 3 +- core/tests/unit/kmnkbd/state_api.tests.cpp | 71 ++++++++++++++-------- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/core/src/mock/mock_processor.cpp b/core/src/mock/mock_processor.cpp index d014716c87..c56f1f40dd 100644 --- a/core/src/mock/mock_processor.cpp +++ b/core/src/mock/mock_processor.cpp @@ -130,7 +130,9 @@ namespace km { } // For this mock processor we only support queuing PERSIST_OPT action items. if (action_item->type == KM_CORE_IT_PERSIST_OPT && action_item->option) { - state->actions().push_persist(update_option(static_cast(action_item->option->scope), action_item->option->key, action_item->option->value)); + state->actions().push_persist(update_option(static_cast(action_item->option->scope), + action_item->option->key, + action_item->option->value)); return true; } else { @@ -175,6 +177,18 @@ namespace km { u"F2 pressed test save.")); break; } + case KM_CORE_VKEY_F3: + { + state->actions().push_persist( + update_option(KM_CORE_OPT_KEYBOARD, + u"__test_point_3", + u"F3 pressed test save 1.")); + state->actions().push_persist( + update_option(KM_CORE_OPT_KEYBOARD, + u"__test_point_4", + u"F3 pressed test save 2.")); + break; + } case KM_CORE_VKEY_F4: state->context().push_marker(KM_CORE_VKEY_QUOTE); diff --git a/core/src/state.cpp b/core/src/state.cpp index c354d3434b..cf5a00c1da 100644 --- a/core/src/state.cpp +++ b/core/src/state.cpp @@ -40,17 +40,16 @@ actions::actions(actions const &other) : std::vector(other) , _option_items_stack(other._option_items_stack) { - // Update all option pointers to point to the new stack + // Update all option pointers to point to the new stack. + + size_t opt_index = 0; for (auto &item : *this) { - if (item.type == KM_CORE_IT_PERSIST_OPT && item.option) { - // Find the corresponding option in the new stack - // The pointers in the original point to positions in other._option_items_stack - // We need to find the equivalent position in our _option_items_stack - auto original_ptr = item.option; - auto original_base = reinterpret_cast(other._option_items_stack.data()); - auto offset = original_ptr - original_base; - if (offset >= 0 && static_cast(offset) < _option_items_stack.size()) { - item.option = &_option_items_stack[offset]; + if (item.type == KM_CORE_IT_PERSIST_OPT) { + if (opt_index < _option_items_stack.size()) { + item.option = &_option_items_stack[opt_index++]; + } else { + // no matching item in the stack; clear pointer. + item.option = nullptr; } } } diff --git a/core/src/state.hpp b/core/src/state.hpp index 098aad93da..0cd452e7fc 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -10,6 +10,7 @@ #include #include +#include #include "keyman_core.h" @@ -27,7 +28,7 @@ using action = km_core_action_item; class actions : public std::vector { - std::vector