fix(android): show updates for lexical models
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

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
This commit is contained in:
Eberhard Beilharz 2026-07-20 15:27:20 +02:00
parent 2c480253b8
commit a332c0d464
No known key found for this signature in database
GPG key ID: E9140597606020D3
8 changed files with 229 additions and 38 deletions

View file

@ -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<String, String> 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);
}
});

View file

@ -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()) {

View file

@ -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<HashMap<String, String>> getLexicalModelsMatchingId(Context context, String lexicalModelId) {
ArrayList<HashMap<String, String>> lexicalModels = new ArrayList<>();
if (lexicalModelsList == null) {
lexicalModelsList = getLexicalModelsList(context);
}
if (lexicalModelsList != null) {
int length = lexicalModelsList.size();
for (int i=0; i < length; i++) {
HashMap<String, String> lmInfo = lexicalModelsList.get(i);
if (lexicalModelId.equals(lmInfo.get(KMManager.KMKey_LexicalModelID))) {
lexicalModels.add(lmInfo);
}
}
}
return lexicalModels;
}
public static HashMap<String, String> getLexicalModelInfo(Context context,int index) {
if (index < 0) {
return null;

View file

@ -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<String, String> 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);

View file

@ -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<Bundle> 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<String, String> lmInfo = KeyboardPickerActivity.getLexicalModelInfo(aContext, index);
String version = lmInfo.get(KMManager.KMKey_Version);
ArrayList<HashMap<String, String>> lmInfos = KeyboardPickerActivity.getLexicalModelsMatchingId(aContext, lexicalModelID);
for (HashMap<String, String> 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(

View file

@ -154,6 +154,7 @@ public class CloudCatalogDownloadCallback implements ICloudDownloadCallback<Data
// The actual update check
CloudDataJsonUtil.processKeyboardPackageUpdateJSON(context, jsonTuple.packagesJSON, updateBundles);
CloudDataJsonUtil.processLexicalModelPackageUpdateJSON(context, jsonTuple.packagesJSON, updateBundles);
// Only add installed kmp keyboards
aDataSet.keyboards.clear();

View file

@ -21,6 +21,7 @@ import com.keyman.engine.cloud.CloudDownloadMgr;
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 @@ public class CloudRepository {
* @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 class CloudRepository {
downloadMetaDataFromServer(context,updateHandler,onSuccess,onFailure);
}
private void mergeLexicalModels(Dataset.LexicalModels datasetModels, List<LexicalModel> newModels) {
if (newModels == null) {
return;
}
List<LexicalModel> existingModels = new ArrayList<>(datasetModels.asList());
List<LexicalModel> 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);

View file

@ -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<LexicalModel> 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<LexicalModel> 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<LexicalModel> 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());
}
}